Files
ISR/CLAUDE.md
T
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

20 KiB
Raw Blame History

CLAUDE.md

Guidance for Claude Code when working in this repository.

Rules

  • Always update README.md when user-facing behaviour changes (flags, endpoints, Docker setup, features), and commit it in the same commit as the code change. README is the external reference; CLAUDE.md documents internals.
  • Run python -m pytest tests/ after changing isr.py, web.py, isrweb/ or webui/js/ (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: test_isr.py, test_loudness.py, test_audiofile.py, test_web.py; tests/conftest.py owns the sys.path setup and the write_wav fixture.

Files

File Purpose
isr.py Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits
web.py Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so python web.py and the webui/ lookup work
isrweb/loudness.py The adaptive loud-section detector. Pure computation, imports nothing from the app — future spectral/VAD work lands here and never touches HTTP
isrweb/audiofile.py What an audio file is and what its name means: the filename clock, FLAC/WAV header parsing, live-header patching. Also a leaf
webui/ Browser UI: index.html, style.css, ES modules in js/ (no build step). Served by web.py (/ and /ui/<path>) — must sit next to web.py and be copied in the Dockerfile
config.ini Recording sources; copy from config.example.ini. [general] gives defaults, every other section is a source (type = stream or type = soundcard)
asound.conf dsnoop device shared_mic so ISR and other ALSA apps can share a soundcard

Commands

python isr.py [config.ini]        # recorder; --list-devices to list ALSA inputs
python web.py                     # web UI on :8080 (--dir, --port, --margin, --min-gap, --min-duration, --analyses-dir)
python -m pytest tests/           # test suite
docker compose up -d / down       # web UI mapped to host port 8050

Dependencies: requests (streams), numpy + soundfile (FLAC output and FLAC analysis/clips — both optional, code degrades gracefully).

Code map

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.

isrweb/loudness.py:

  • 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.

isrweb/audiofile.py:

  • 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-labels 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 " + the #dayhls- " · analysed" / progress suffix) is its name.
  • The accessible text of a button built as aria-hidden arrow span + text must not start the text node with a space (reads as an indent) — keep separator spaces inside the aria-hidden span.
  • Never disable or replace the focused element: busy buttons get aria-disabled + a guard, and code that replaces a container moves focus to its replacement (analyse() → first chip).
  • [hidden]{display:none !important} in style.css is load-bearing: author display rules (flex chips, inline-flex REC badge, flex clip bar) otherwise beat the UA [hidden] rule — the REC badge once showed on every row that way.

Verifying changes

  • python -m pytest tests/ covers the recorder (test_isr.py) and the detector (test_web.py).
  • There is no JS toolchain and no node on the dev box. test_web.py checks every relative module import resolves. After editing webui/, cross-check every $('x')/getElementById('x') against an id="x" declaration, then smoke-test in a real browser: headless Edge is installed (C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe --headless=new --virtual-time-budget=60000 --dump-dom <url>). Serve web.py over generated WAVs with WEBUI_DIR pointed at a scratch copy of webui/ plus a harness page that iframes /, drives it (click, dispatch keydown, stub confirm), and writes PASS/FAIL lines into its own DOM for --dump-dom to print.
  • Endpoint smoke pattern: write a temp WAV/FLAC with a known loud burst, subclass web._Handler with recordings_dir/analyses_dir pointing at the temp dir, serve web._Server(('127.0.0.1', 0), H) in a daemon thread, then hit /api/analyze and /api/clip with urllib — assert section start/score and that Content-Length == len(body) == 44 + frames × channels × 2.
  • Dev box is Windows / PowerShell 5.1. Multi-line commit messages: use the Bash tool with git commit -F - <<'EOF' — PowerShell here-strings containing quotes get mangled into separate arguments.

Non-obvious internals

  • Recorder/web coupling is one file: RecorderManager atomically writes recordings/status.json every 2 s listing in-progress files; deleted on clean shutdown. web.py reads it to show REC badges and to refuse analyse/cut/delete on active files. In-progress WAV/FLAC headers are unfinalized, so durations are not read for active files.
  • Stream splits: OGG/Opus/FLAC codec headers are extracted from the first ~16 KB of each connection and prepended to every split file so each file plays standalone. A new file is always opened on reconnect (gap in stream). MP3/AAC need no headers.
  • Split timing: files split at clock-aligned boundaries (get_next_split_time()), e.g. split_minutes = 60 → on the hour.
  • Filename is the clock — fixed format, not configurable. Recordings are named %Y%m%d_%H%M%S.<ext> (the start time). This is hardcoded as FILENAME_FORMAT, defined in both isr.py (recorder writes it) and web.py (reads it back) — the two copies must stay in sync. There is no filename_pattern config option (removed; web.py can't see config.ini, so a configurable pattern would break parsing). web.py derives the displayed DATE column from the filename via _recording_start() (falling back to mtime only for non-standard names — mtime is the last write ≈ end, not the start). Cut downloads are named by the wall-clock span they cover via _cut_filename(): a 22:31:30→22:32:30 slice of 20260523_220000.flac becomes 20260523_22-31-30_22-32-30.flac; non-standard source names fall back to <stem>_cut_<start>s-<end>s.
  • ALSA: capture spawns arecord as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: default → exact hw:X,Y → partial name → fallback to any literal ALSA PCM name (so shared_mic from asound.conf works without appearing in arecord -l).
  • Shutdown: SIGTERM is converted to KeyboardInterrupt in main(); RecorderManager.stop() joins all threads against a single shared 25 s deadline to stay inside Docker's stop_grace_period: 30s.
  • Loud-section detection is adaptive — do not regress it to an absolute threshold. Per-window dB is compared against a rolling noise floor (NOISE_PERCENTILE-th percentile per NOISE_BLOCK_SECONDS block, min-smoothed over ±2 blocks so events can't raise their own floor; clamped to ≥ MIN_RMS). A section needs margin dB of prominence and carries a score used for ranking: peak dB above floor, capped by the sharpest rise within ONSET_SECONDS (0.5 s) — so a short (~10 s) swell that outruns the 30 s floor blocks still flags but scores ≈ 0 and sinks in the U/I highlight ranking, while sharp events keep their full prominence. A section starting in the first 0.5 s of a file is scored against the floor instead (events cut off by a file split must not be punished as swells). Do not regress the scoring to raw peak, and do not fight swells with a higher margin. If flagging itself (not just ranking) ever needs improving, the next step is a spectral filter or optional Silero VAD over candidate sections. Sections shorter than min_duration (default 0.5 s, after min_gap merging) are discarded — without this, isolated 100 ms pops (clicks, single raindrops) produced thousands of zero-length sections per day. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Tests in tests/test_web.py.
  • Analysis params are coupled in five places. CLI --margin/--min-gap/--min-duration → /api/config → UI inputs #margin-input/#min-gap-input/#min-duration-input → /api/analyze query params → cache JSON keys. Renaming or adding a param means touching all five plus analysisParams()/cachedParamsMatch()/initSettings() (settings.js), api.analyze() and _cached_analysis_params(). Query-string parsing/clamping for all three goes through the single _qs_float() helper (see the threshold→margin change c84b7d8 and the min_duration addition).
  • Analysis cache: results stored as <analyses-dir>/<file>.analysis.json keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is read-only for the web container, so docker-compose layers a read-write ./recordings/analyses bind mount over it. _cached_analysis_params() just json.loads the file — key order carries no meaning. (It used to regex the four param keys out of the first 256 bytes, which forced them to be written first; that was worth it only while caches embedded the RMS arrays, and those are gone.) detector is DETECTOR_VERSION: bump it whenever detection/scoring changes make old cached results wrong (v2 = onset-capped scores, v3 = a section open at EOF ends at its last loud window rather than at the file duration); caches with another version (or missing keys) never match and get overwritten on the next analyse.
  • Analyze responses: /api/analyze returns only sections and duration — no RMS data of any kind. rms_display (~800-point waveform preview), the full per-window list, and window (the window length, which only existed to place RMS bars) were all dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed rms/rms_display have both keys popped when served from cache.
  • Section playback uses clips, not seeks: /api/clip?file&start&end decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at CLIP_MAX_SECONDS), Cache-Control: private so re-listening is free. The UI plays chips/J-K through the bottom clip bar (clipQueue in webui/js/clips.js); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC without a SEEKTABLE, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in <audio> is inherently slow and must not be reintroduced as the primary navigation. Server-side sf.SoundFile.seek() on local disk is fast and frame-accurate.
  • HTTP/1.1 keep-alive: _Handler.protocol_version = 'HTTP/1.1'; every response path must set an accurate Content-Length. _copy_to_response() force-closes the connection if it under-delivers (file truncated mid-serve).
  • Live playback: for files listed in status.json, /stream/ patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get Cache-Control: no-store. WAV: _live_wav_header derives sizes from the byte count. FLAC: _live_flac_header parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
  • Path safety: every file parameter in web.py goes through _safe_path(), which resolves and verifies the path stays inside the recordings dir.
  • dsnoop in Docker: sharing the soundcard requires asound.conf on the host and ipc: host in docker-compose (dsnoop uses shared memory across the container boundary).