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>
20 KiB
CLAUDE.md
Guidance for Claude Code when working in this repository.
Rules
- Always update
README.mdwhen 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 changingisr.py,web.py,isrweb/orwebui/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.pyowns the sys.path setup and thewrite_wavfixture.
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_SAMPLESis 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/analyzepayload.
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 listingdatefield 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 itsprefixargument,no_store) and_download()(Content-Disposition) both go through it, and it is what makesdo_HEADa body-lessdo_GET. Plus_copy_to_response(),_safe_path()(path traversal guard)._ui()serveswebui/(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-announcelive region),chipToggle()(collapses chip lists > 12 behind anaria-expandedbutton),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)._idxis 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 viafileIdxByName().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 anaria-expandedtoggle 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 fromfileStartEpoch(f.date)— the filename clock — with in-file offsets as fallback; the filename/offset lives in the tooltip.playClip()builds onetextstring used forlabel.textContent, theannounce()message and the Media Session title — they must never diverge (a past bug had the announcement readClip N of M: …while the label read the wall-clock form). Two cursors, deliberately independent:clipCursoris the time-order position and follows every play;scoreCursoris the loudness walk's own position inscoreOrder()and is moved only by U/I (and "Next loudest" auto-advance) instepClip'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.scoreCursorresets to-1on every explicit jump / queue (re-)arm (hideClipBar,armQueue,playFrom), so the next U/I re-anchors onclipCursorelse the loudest.playClip(i, byScore): whenbyScorethe count showsscoreCursor(suffix "by loudness"), elsei("by time") — the suffix tells the blind user which dimension the count is in. Both U/I walks (stepClipand the in-player branch ofnav.navigate()) sharenextInOrder(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; updatesf.cached_analysisclient-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 fromdayHlSections, keyed bypanel.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. Grouparia-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 carriesdata-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/Okeydownlistener andnavigate()— clip queue first, in-playercurrentTimestepping as the fallback; O =openClipInFile(). Media Session previous/next track callnavigate(∓1)(AirPods, lock screen, keyboard media keys). J/K (and Prev/Next) always step in time order; U/I walkscoreOrder()with no top-N cutoff (a#clip-topinput and#clip-hl-onlycheckbox were removed deliberately; J/K must never be affected by an auto-advance setting). Auto-advance is theinput[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-labelon 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 + textmust 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: authordisplayrules (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
nodeon the dev box.test_web.pychecks every relative module import resolves. After editingwebui/, cross-check every$('x')/getElementById('x')against anid="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>). Serveweb.pyover generated WAVs withWEBUI_DIRpointed at a scratch copy ofwebui/plus a harness page that iframes/, drives it (click, dispatchkeydown, stubconfirm), and writes PASS/FAIL lines into its own DOM for--dump-domto print. - Endpoint smoke pattern: write a temp WAV/FLAC with a known loud burst, subclass
web._Handlerwithrecordings_dir/analyses_dirpointing at the temp dir, serveweb._Server(('127.0.0.1', 0), H)in a daemon thread, then hit/api/analyzeand/api/clipwith urllib — assert section start/score and thatContent-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:
RecorderManageratomically writesrecordings/status.jsonevery 2 s listing in-progress files; deleted on clean shutdown.web.pyreads 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 asFILENAME_FORMAT, defined in bothisr.py(recorder writes it) andweb.py(reads it back) — the two copies must stay in sync. There is nofilename_patternconfig option (removed;web.pycan't seeconfig.ini, so a configurable pattern would break parsing).web.pyderives 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 of20260523_220000.flacbecomes20260523_22-31-30_22-32-30.flac; non-standard source names fall back to<stem>_cut_<start>s-<end>s. - ALSA: capture spawns
arecordas a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution:default→ exacthw:X,Y→ partial name → fallback to any literal ALSA PCM name (soshared_micfrom asound.conf works without appearing inarecord -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'sstop_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 perNOISE_BLOCK_SECONDSblock, min-smoothed over ±2 blocks so events can't raise their own floor; clamped to ≥MIN_RMS). A section needsmargindB of prominence and carries ascoreused for ranking: peak dB above floor, capped by the sharpest rise withinONSET_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 thanmin_duration(default 0.5 s, aftermin_gapmerging) 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 intests/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/analyzequery params → cache JSON keys. Renaming or adding a param means touching all five plusanalysisParams()/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 changec84b7d8and the min_duration addition). - Analysis cache: results stored as
<analyses-dir>/<file>.analysis.jsonkeyed 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/analysesbind mount over it._cached_analysis_params()justjson.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.)detectorisDETECTOR_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/analyzereturns onlysectionsandduration— no RMS data of any kind.rms_display(~800-point waveform preview), the full per-window list, andwindow(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 embedrms/rms_displayhave both keys popped when served from cache. - Section playback uses clips, not seeks:
/api/clip?file&start&enddecodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped atCLIP_MAX_SECONDS),Cache-Control: privateso re-listening is free. The UI plays chips/J-K through the bottom clip bar (clipQueueinwebui/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-sidesf.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 accurateContent-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 getCache-Control: no-store. WAV:_live_wav_headerderives sizes from the byte count. FLAC:_live_flac_headerparses 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.pygoes through_safe_path(), which resolves and verifies the path stays inside the recordings dir. - dsnoop in Docker: sharing the soundcard requires
asound.confon the host andipc: hostin docker-compose (dsnoop uses shared memory across the container boundary).