From c91264c2535d77d4a7a3bee1b3611d47dca1529c Mon Sep 17 00:00:00 2001 From: jonathan Date: Thu, 30 Jul 2026 23:14:54 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20web.py=20=E2=80=94=20detect?= =?UTF-8?q?or=20and=20audio-format=20helpers=20into=20isrweb/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 18 +- Dockerfile | 1 + README.md | 2 +- isrweb/__init__.py | 1 + isrweb/audiofile.py | 250 +++++++++++++++++++++++ isrweb/loudness.py | 229 +++++++++++++++++++++ tests/conftest.py | 30 +++ tests/test_audiofile.py | 35 ++++ tests/test_isr.py | 8 +- tests/test_loudness.py | 176 ++++++++++++++++ tests/test_web.py | 239 ++-------------------- web.py | 440 ++-------------------------------------- 12 files changed, 766 insertions(+), 663 deletions(-) create mode 100644 isrweb/__init__.py create mode 100644 isrweb/audiofile.py create mode 100644 isrweb/loudness.py create mode 100644 tests/conftest.py create mode 100644 tests/test_audiofile.py create mode 100644 tests/test_loudness.py diff --git a/CLAUDE.md b/CLAUDE.md index 8ee77aa..4af8041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,14 +5,16 @@ 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` or `web.py` (tests cover the recorder and the loud-section detector). +- Run `python -m pytest tests/` after changing `isr.py`, `web.py` or `isrweb/` (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, 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.html` 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.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 | | `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 | @@ -30,11 +32,17 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC ## 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. + +`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. -- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice, `_wav_header()` builds the 44-byte PCM header. + +`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). +- 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). `webui.html` (one `