# 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/`) — 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 ```bash python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --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 `