Compare commits

..

3 Commits

Author SHA1 Message Date
admin e4d82483b5 docs: code map, regression findings, and HTTP API reference
CLAUDE.md gains a function-level code map of web.py/webui.html, a
"verifying changes" section (test scope, no-node UI checks, endpoint
smoke pattern, Windows commit-message gotcha), and findings recorded as
guard rails: why detection must stay adaptive (fixed threshold produced
~600 useless sections/day), why section playback must stay clip-based
(libsndfile FLACs have no SEEKTABLE so browser seeks bisect the file),
and the five places analysis params are coupled.

README gains the adaptive-detection and clip-review features up top, an
HTTP API table for scripting, and a corrected Docker analyses-cache
paragraph (the rw mount is ./recordings/analyses layered over the ro
recordings mount, not a separate ./analyses dir).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:21:37 +02:00
admin 119e631faf feat: instant section playback via server-rendered clips
Add /api/clip: decodes a WAV/FLAC slice server-side and returns a small
standalone 16-bit WAV with exact Content-Length (capped at 600s, cached
client-side since finished recordings are immutable). Active recordings
are refused like analyse/cut/delete.

Section chips and J/K now play these clips through a bottom player bar
instead of seeking the full recording - FLACs have no seek table, so
browser seeks bisected hundreds of MB with Range requests and playback
lagged or never started. The bar steps through a queue (one file's
sections or a whole day's via Highlights), auto-advances to the next
section on end for continuous review, and "Open in file" jumps to the
same position in the full recording for context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:13:39 +02:00
admin c84b7d8222 feat: adaptive noise-floor loudness detection with section scoring
Replace the fixed RMS threshold with prominence over a rolling noise
floor (20th percentile per 30s block, min-smoothed so events cannot
raise their own floor, clamped to -54 dBFS). Slow ambience changes such
as rain or daytime traffic hum move the floor instead of flagging
everything; sections now need `margin` dB (default 12) of prominence.

Each section carries a score (peak dB above floor); day-highlight chips
show the top 50 by score when there are too many to list, so the most
striking events are reviewed first.

--threshold is replaced by --margin; analysis caches are now keyed by
margin+min_gap, old threshold-keyed caches never match and are
overwritten on the next analyse. Detector covered by tests/test_web.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:36:48 +02:00
5 changed files with 468 additions and 106 deletions
+28 -4
View File
@@ -5,7 +5,7 @@ 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` (tests cover the recorder only). - Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector).
## Files ## Files
@@ -21,12 +21,33 @@ Guidance for Claude Code when working in this repository.
```bash ```bash
python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
python web.py # web UI on :8080 (--dir, --port, --threshold, --min-gap, --analyses-dir) python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --analyses-dir)
python -m pytest tests/ # test suite python -m pytest tests/ # test suite
docker compose up -d / down # web UI mapped to host port 8050 docker compose up -d / down # web UI mapped to host port 8050
``` ```
Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC/waveform analysis — both optional, code degrades gracefully). Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC analysis/clips — both optional, code degrades gracefully).
## Code map
`web.py`:
- 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.
- 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):
- Clip review: `clipQueue`/`clipCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue.
- J/K: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed.
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render), `cachedParamsMatch()` (autoload guard).
## 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. After editing `webui.html`, cross-check every `getElementById('x')` against an `id="x"` declaration, and smoke-test endpoints.
- 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 ## Non-obvious internals
@@ -35,8 +56,11 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC/
- **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour. - **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
- **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`.
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by threshold+min_gap; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so the cache uses a separate `./analyses` bind mount. The `threshold` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. - **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` (peak dB above floor) used for ranking. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Known limitation: a short (~10 s) swell on a quiet street still flags because the floor blocks are 30 s; the planned fix is an onset/spectral filter or optional Silero VAD, **not** a higher margin. Tests in `tests/test_web.py`.
- **Analysis params are coupled in five places.** CLI `--margin`/`--min-gap``/api/config` → UI inputs `#margin-input`/`#min-gap-input``/api/analyze` query params → cache JSON head keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` (see the threshold→margin change, commit `c84b7d8`).
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap; 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 `margin` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. Old `threshold`-keyed caches never match and get overwritten on the next analyse.
- **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger. - **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger.
- **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.
- **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.
+28 -7
View File
@@ -13,7 +13,9 @@ Records from multiple simultaneous sources — Icecast/HTTP streams and ALSA sou
- OGG / Opus / FLAC header injection so every split file is independently playable - OGG / Opus / FLAC header injection so every split file is independently playable
- Auto-reconnect on stream drops or device errors - Auto-reconnect on stream drops or device errors
- WAV and FLAC output for soundcard sources - WAV and FLAC output for soundcard sources
- Web UI to browse, download, and analyse recordings - Web UI to browse, play, cut, and download recordings — including files still being written
- **Loud-event detection** — adaptive: events are flagged by how far they rise above the rolling background noise, not by absolute level, and ranked by score
- **Clip review** — detected sections play as instant server-rendered clips with auto-advance, so a whole day can be reviewed like a highlights reel
--- ---
@@ -21,7 +23,7 @@ Records from multiple simultaneous sources — Icecast/HTTP streams and ALSA sou
```bash ```bash
pip install requests # stream recording pip install requests # stream recording
pip install numpy soundfile # FLAC output + web waveform analysis (optional) pip install numpy soundfile # FLAC output + web analysis/clips for FLAC (optional)
cp config.example.ini config.ini cp config.example.ini config.ini
# edit config.ini to add your sources # edit config.ini to add your sources
@@ -150,7 +152,7 @@ strftime codes are substituted at split time. The file extension is added automa
python web.py # serves ./recordings on port 8080 python web.py # serves ./recordings on port 8080
python web.py --dir /path/to/audio # custom recordings directory python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold 01 (default 0.05) python web.py --margin 15 # dB above background noise for a section to count as loud (default 12)
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2) python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
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)
``` ```
@@ -160,11 +162,11 @@ The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at star
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 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 highlights** — click **★ Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. - **Day highlights** — click **★ Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. When a day has more sections than fit as chips, the chips show the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; J/K still steps through all sections in time order.
- **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. - **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.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. 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 threshold and min-gap 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. - **Waveform analysis** — on demand per file; computes RMS per 100 ms window and marks sections that stand out above the background. 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** (its peak dB above the floor) used to rank sections by how much they stand out. 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 and min-gap 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 1530 s) when a single event generates many timestamps due to brief quiet gaps within it. - **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 1530 s) when a single event generates many timestamps due to brief quiet gaps within it.
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing. - **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. **J** / **K** (or ⏮ / ⏭) step through the queued sections — one file's, or a whole day's after ★ Highlights — and **Auto-advance** plays the next section when one ends, turning a day's detections into a continuous review reel. **⤴ Open in file** switches to the full recording at the same position for context; each chip click also pre-fills the cut panel.
- **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). - **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).
- **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>` and re-renders the table.
@@ -173,6 +175,25 @@ Shows recordings grouped by day with collapsible sections. Features:
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached waveforms 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 waveforms 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, `role=img` on SVG waveforms. - **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
### HTTP API
Everything the UI does goes through these endpoints, so they can also be scripted:
| Endpoint | Description |
|----------|-------------|
| `GET /api/files` | File listing with size, mtime, duration, recording state, cached-analysis params |
| `GET /api/analyze?file=&margin=&min_gap=` | Loud-section analysis: `rms_display` (~800-point waveform), scored `sections`, `duration` |
| `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 /stream/<name>` | Inline playback with HTTP Range support; live files get an on-the-fly patched header |
| `GET /download/<name>` | Raw file download |
| `GET /api/status` | Currently recording files (`status.json` passthrough) |
| `GET /api/storage` | Disk free/total |
| `GET /api/config` | Server-side defaults for margin and min-gap (seeds the UI controls) |
| `DELETE /api/files/<name>` | Delete a recording and its analysis cache |
Analysis, clips, cut, and delete return `409` for files that are still being recorded.
--- ---
## How it works ## How it works
@@ -216,7 +237,7 @@ docker compose down && docker compose up -d --build
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount). **Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
**Analysis cache in Docker:** The web container mounts `./recordings` read-only, so analysis cache files are written to a separate `./analyses` bind mount (mapped to `/analyses` inside the container). This directory is created automatically by Docker Compose on first run. Cache files are stored as `analyses/<filename>.analysis.json` on the host. **Analysis cache in Docker:** The web container mounts `./recordings` read-only; a separate read-write bind mount of `./recordings/analyses` is layered on top so analysis cache files can still be written. The directory is created automatically by Docker Compose on first run. Cache files are stored as `recordings/analyses/<filename>.analysis.json` on the host.
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host: **File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
```bash ```bash
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the adaptive loud-section detector in web.py."""
import math
from web import _loud_sections, _noise_floor_db
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):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap)
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_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)
+195 -31
View File
@@ -3,13 +3,14 @@
ISR Web — Browse and download recorded audio files. ISR Web — Browse and download recorded audio files.
Shows a chronological table of all recordings, allows inline playback, Shows a chronological table of all recordings, allows inline playback,
download, and analyses WAV/FLAC files for loud sections using RMS. download, and analyses WAV/FLAC files for sections that stand out above the
background noise.
Usage: Usage:
python web.py # serves recordings/ on port 8080 python web.py # serves recordings/ on port 8080
python web.py --dir /path/to/audio # custom recordings directory python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05) python web.py --margin 15 # dB above noise floor (default 12)
""" """
import argparse import argparse
@@ -46,9 +47,16 @@ except ImportError:
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'} AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
LOUD_THRESHOLD = 0.05 # RMS 01 scale; sections above this are "interesting" 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_GAP_SECONDS = 2.0 # merge loud sections separated by less 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
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
MIME_TYPES = { MIME_TYPES = {
'.wav': 'audio/wav', '.wav': 'audio/wav',
'.mp3': 'audio/mpeg', '.mp3': 'audio/mpeg',
@@ -211,6 +219,17 @@ def _live_flac_header(path: Path, size: int):
return None 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): def _get_audio_duration(path: Path):
"""Return duration in seconds for any supported audio file, or None.""" """Return duration in seconds for any supported audio file, or None."""
ext = path.suffix.lower() ext = path.suffix.lower()
@@ -254,33 +273,64 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
yield round(rms, 5) 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, def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list: margin_db: float, min_gap: float = MIN_GAP_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, used
by the UI to rank sections by how much they stand out."""
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)
sections = [] sections = []
start_t = None start_t = None
last_loud_t = None last_loud_t = None
peak = 0.0
for i, rms in enumerate(rms_values): for i, d in enumerate(db):
t = i * window_dur t = i * window_dur
if rms >= threshold: floor_eff = max(floor[i], min_db)
if d >= floor_eff + margin_db:
if start_t is None: if start_t is None:
start_t = t start_t = t
peak = 0.0
last_loud_t = t last_loud_t = t
peak = max(peak, d - floor_eff)
else: else:
if start_t is not None and (t - last_loud_t) > min_gap: if start_t is not None and (t - last_loud_t) > min_gap:
sections.append({'start': round(start_t, 1), sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1)}) 'end': round(last_loud_t + window_dur, 1),
'score': round(peak, 1)})
start_t = None start_t = None
last_loud_t = None last_loud_t = None
if start_t is not None: if start_t is not None:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)}) sections.append({'start': round(start_t, 1), 'end': round(duration, 1),
'score': round(peak, 1)})
return sections return sections
def _package_result(rms_values: list, framerate: int, n_frames: int, def _package_result(rms_values: list, framerate: int, n_frames: int,
window_samples: int, threshold: float, window_samples: int, margin_db: float,
min_gap: float = MIN_GAP_SECONDS) -> dict: min_gap: float = MIN_GAP_SECONDS) -> dict:
window_dur = window_samples / framerate window_dur = window_samples / framerate
duration = n_frames / framerate duration = n_frames / framerate
@@ -295,14 +345,14 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
# only renders rms_display (~800 points), and the full list is ~45x larger. # only renders rms_display (~800 points), and the full list is ~45x larger.
return { return {
'rms_display': rms_display, 'rms_display': rms_display,
'sections': _loud_sections(rms_values, window_dur, duration, threshold, min_gap), 'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap),
'duration': round(duration, 2), 'duration': round(duration, 2),
'window': round(window_dur, 4), 'window': round(window_dur, 4),
} }
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES, def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD, margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS) -> dict: min_gap: float = MIN_GAP_SECONDS) -> dict:
try: try:
with wave.open(str(path), 'rb') as wf: with wave.open(str(path), 'rb') as wf:
@@ -315,11 +365,11 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap) return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES, def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD, margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS) -> dict: min_gap: float = MIN_GAP_SECONDS) -> dict:
"""Analyse a FLAC file for loudness. Requires numpy and soundfile.""" """Analyse a FLAC file for loudness. Requires numpy and soundfile."""
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE: if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
@@ -342,7 +392,7 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap) return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -355,18 +405,19 @@ 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 threshold/min_gap from a cache file without parsing the whole """Read just margin/min_gap from a cache file without parsing the whole
JSON (the embedded result can be hundreds of KB). Relies on the writer in JSON (the embedded result can be hundreds of KB). Relies on the writer in
_api_analyze putting these two keys first.""" _api_analyze putting these two keys first. Caches written by the old
fixed-threshold detector have no margin key and simply never match."""
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) head = fh.read(256)
except OSError: except OSError:
return None return None
m = re.search(r'"threshold":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head) m = re.search(r'"margin":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head)
if not m: if not m:
return None return None
return {'threshold': float(m.group(1)), 'min_gap': float(m.group(2))} return {'margin': float(m.group(1)), 'min_gap': float(m.group(2))}
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path): def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
@@ -465,7 +516,7 @@ class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings' recordings_dir: str = 'recordings'
analyses_dir: str = 'recordings/analyses' analyses_dir: str = 'recordings/analyses'
threshold: float = LOUD_THRESHOLD margin_db: float = MARGIN_DB
min_gap: float = MIN_GAP_SECONDS min_gap: float = MIN_GAP_SECONDS
def do_DELETE(self): def do_DELETE(self):
@@ -495,6 +546,8 @@ class _Handler(BaseHTTPRequestHandler):
self._api_config() self._api_config()
elif p == '/api/cut': elif p == '/api/cut':
self._api_cut(qs) self._api_cut(qs)
elif p == '/api/clip':
self._api_clip(qs)
elif p.startswith('/download/'): elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):])) self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'): elif p.startswith('/stream/'):
@@ -529,10 +582,10 @@ class _Handler(BaseHTTPRequestHandler):
return return
try: try:
threshold = float(qs.get('threshold', [self.threshold])[0]) margin = float(qs.get('margin', [self.margin_db])[0])
threshold = max(0.0, min(1.0, threshold)) margin = max(1.0, min(60.0, margin))
except (ValueError, TypeError): except (ValueError, TypeError):
threshold = self.threshold margin = self.margin_db
try: try:
min_gap = float(qs.get('min_gap', [self.min_gap])[0]) min_gap = float(qs.get('min_gap', [self.min_gap])[0])
@@ -549,7 +602,7 @@ class _Handler(BaseHTTPRequestHandler):
cache_path = _analysis_cache_path(analyses_base, recordings_base, path) cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
try: try:
cached = json.loads(cache_path.read_text('utf-8')) cached = json.loads(cache_path.read_text('utf-8'))
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap: if cached.get('margin') == margin and cached.get('min_gap') == min_gap:
payload = dict(cached['result']) payload = dict(cached['result'])
payload.pop('rms', None) # caches written before the full-RMS field was dropped payload.pop('rms', None) # caches written before the full-RMS field was dropped
payload['cached'] = True payload['cached'] = True
@@ -560,12 +613,12 @@ class _Handler(BaseHTTPRequestHandler):
ext = path.suffix.lower() ext = path.suffix.lower()
if ext == '.wav': if ext == '.wav':
result = analyze_wav(path, threshold=threshold, min_gap=min_gap) result = analyze_wav(path, margin_db=margin, min_gap=min_gap)
elif ext == '.flac': elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE): if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile') self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
return return
result = analyze_flac(path, threshold=threshold, min_gap=min_gap) result = analyze_flac(path, margin_db=margin, min_gap=min_gap)
else: else:
self._json_err(400, f'Loudness analysis is not available for {ext} files') self._json_err(400, f'Loudness analysis is not available for {ext} files')
return return
@@ -573,7 +626,9 @@ 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')
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8') # margin and min_gap MUST stay first: _cached_analysis_params reads
# only the first 256 bytes of this file
tmp.write_text(json.dumps({'margin': margin, 'min_gap': min_gap, 'result': result}), 'utf-8')
os.replace(tmp, cache_path) os.replace(tmp, cache_path)
except Exception as e: except Exception as e:
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True) print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
@@ -690,7 +745,7 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, data.encode(), 'application/json') self._send(200, data.encode(), 'application/json')
def _api_config(self): def _api_config(self):
data = json.dumps({'threshold': self.threshold, 'min_gap': self.min_gap}) data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap})
self._send(200, data.encode(), 'application/json') self._send(200, data.encode(), 'application/json')
def _api_delete(self, filename: str): def _api_delete(self, filename: str):
@@ -790,6 +845,114 @@ class _Handler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass pass
def _api_clip(self, qs):
"""Serve a section of a WAV/FLAC file as a small standalone WAV.
Decoding the slice server-side means the browser plays a tiny file
instantly instead of seeking inside a multi-hundred-MB recording
(FLACs have no seek table, so browser seeks bisect the whole file
with Range requests)."""
filename = qs.get('file', [None])[0]
start_s = qs.get('start', [None])[0]
end_s = qs.get('end', [None])[0]
if not filename or start_s is None or end_s is None:
self._json_err(400, 'missing file, start, or end parameter')
return
try:
start = max(0.0, float(start_s))
end = float(end_s)
except (ValueError, TypeError):
self._json_err(400, 'start and end must be numbers (seconds)')
return
if end <= start:
self._json_err(400, 'end must be > start')
return
end = min(end, start + CLIP_MAX_SECONDS)
path = self._safe_path(filename)
if path is None:
return
if self._is_active(filename):
self._json_err(409, 'File is currently being recorded — clips unavailable until recording stops')
return
ext = path.suffix.lower()
try:
if ext == '.wav':
self._clip_wav(path, start, end)
elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC clips require: pip install numpy soundfile')
return
self._clip_flac(path, start, end)
else:
self._json_err(400, f'Clips are not available for {ext} files')
except (ConnectionError, BrokenPipeError):
raise
except Exception as e:
self._json_err(500, f'Clip failed: {e}')
def _send_clip_headers(self, header: bytes, n_frames: int, channels: int,
sampwidth: int):
self.send_response(200)
self.send_header('Content-Type', 'audio/wav')
self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth))
# Finished recordings are immutable, so stepping back to a section
# replays the clip from the browser cache
self.send_header('Cache-Control', 'private, max-age=86400')
self.end_headers()
self.wfile.write(header)
def _clip_wav(self, path: Path, start: float, end: float):
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
f0 = min(int(start * framerate), n_frames)
f1 = min(int(end * framerate), n_frames)
if f1 <= f0:
self._json_err(400, 'clip range is beyond the end of the file')
return
header = _wav_header(f1 - f0, channels, framerate, sampwidth)
self._send_clip_headers(header, f1 - f0, channels, sampwidth)
wf.setpos(f0)
remaining = f1 - f0
while remaining > 0:
chunk = wf.readframes(min(32768, remaining))
if not chunk:
self.close_connection = True # under-delivered
break
self.wfile.write(chunk)
remaining -= len(chunk) // (channels * sampwidth)
def _clip_flac(self, path: Path, start: float, end: float):
with sf.SoundFile(path) as f:
framerate = f.samplerate
channels = f.channels
n_frames = len(f)
f0 = min(int(start * framerate), n_frames)
f1 = min(int(end * framerate), n_frames)
if f1 <= f0:
self._json_err(400, 'clip range is beyond the end of the file')
return
header = _wav_header(f1 - f0, channels, framerate, 2)
self._send_clip_headers(header, f1 - f0, channels, 2)
f.seek(f0)
remaining = f1 - f0
while remaining > 0:
frames = f.read(min(32768, remaining), dtype='int16', always_2d=True)
if len(frames) == 0:
self.close_connection = True # under-delivered
break
self.wfile.write(frames.tobytes())
remaining -= len(frames)
def _is_active(self, filename: str) -> bool: def _is_active(self, filename: str) -> bool:
"""True if isr.py reports this file as currently being recorded.""" """True if isr.py reports this file as currently being recorded."""
try: try:
@@ -873,8 +1036,9 @@ def main():
help='HTTP port (default: 8080)') help='HTTP port (default: 8080)')
parser.add_argument('--host', default='0.0.0.0', parser.add_argument('--host', default='0.0.0.0',
help='Bind address (default: 0.0.0.0)') help='Bind address (default: 0.0.0.0)')
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD, parser.add_argument('--margin', type=float, default=MARGIN_DB,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})') help=f'dB above the background noise floor for a section '
f'to count as loud (default: {MARGIN_DB})')
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS, parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})') help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
parser.add_argument('--analyses-dir', default=None, parser.add_argument('--analyses-dir', default=None,
@@ -893,7 +1057,7 @@ def main():
class Handler(_Handler): class Handler(_Handler):
recordings_dir = str(rec_dir) recordings_dir = str(rec_dir)
analyses_dir = str(_analyses_dir) analyses_dir = str(_analyses_dir)
threshold = args.threshold margin_db = args.margin
min_gap = args.min_gap min_gap = args.min_gap
server = _Server((args.host, args.port), Handler) server = _Server((args.host, args.port), Handler)
@@ -901,7 +1065,7 @@ def main():
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}")
print(f"Analyses dir: {analyses_dir}") print(f"Analyses dir: {analyses_dir}")
print(f"Loud threshold: {args.threshold}") print(f"Loudness margin: {args.margin} dB above noise floor")
if not NUMPY_AVAILABLE: if not NUMPY_AVAILABLE:
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable") print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
elif not SOUNDFILE_AVAILABLE: elif not SOUNDFILE_AVAILABLE:
+148 -64
View File
@@ -121,6 +121,16 @@ table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);
svg.day-timeline{display:block;width:100%;height:22px} svg.day-timeline{display:block;width:100%;height:22px}
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px; .day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px} color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
/* 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 28px;display:flex;align-items:center;
gap:10px;flex-wrap:wrap}
#clip-bar audio{flex:1 1 240px;min-width:180px;height:32px}
#clip-label{font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
#clip-auto-label{font-size:12px;color:var(--muted);display:flex;align-items:center;
gap:4px;white-space:nowrap;cursor:pointer}
body.clip-open{padding-bottom:70px}
</style> </style>
</head> </head>
<body> <body>
@@ -133,10 +143,10 @@ svg.day-timeline{display:block;width:100%;height:22px}
<button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button> <button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
</header> </header>
<div class="controls-bar"> <div class="controls-bar">
<label for="threshold-input">Analysis threshold:</label> <label for="margin-input">Loudness margin:</label>
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05" <input type="number" id="margin-input" min="1" max="60" step="1" value="12"
aria-describedby="threshold-hint"> aria-describedby="margin-hint">
<span id="threshold-hint" class="controls-hint">RMS amplitude 01 (linear; 0.05 ≈ 26 dBFS) · sections above this are marked loud</span> <span id="margin-hint" class="controls-hint">dB above background noise — sections that rise this far above the rolling noise floor are marked loud</span>
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label> <label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" <input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
aria-describedby="preroll-hint"> aria-describedby="preroll-hint">
@@ -159,6 +169,15 @@ svg.day-timeline{display:block;width:100%;height:22px}
<div id="tbody" role="region" aria-label="Recordings archive"></div> <div id="tbody" role="region" aria-label="Recordings archive"></div>
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div> <div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
</div> </div>
<div id="clip-bar" hidden role="region" aria-label="Section clip player">
<button id="clip-prev" aria-label="Previous section (J)"></button>
<button id="clip-next" aria-label="Next section (K)"></button>
<span id="clip-label"></span>
<audio id="clip-audio" controls preload="auto" aria-label="Section clip playback"></audio>
<label id="clip-auto-label"><input type="checkbox" id="clip-auto" checked> Auto-advance</label>
<button id="clip-context" title="Open the full recording at this position">⤴ Open in file</button>
<button id="clip-close" aria-label="Close clip player"></button>
</div>
<script> <script>
const esc = s => String(s) const esc = s => String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;') .replace(/&/g,'&amp;').replace(/</g,'&lt;')
@@ -203,9 +222,8 @@ let activePlayerIdx = null;
let allFiles = []; let allFiles = [];
// dayId -> boolean, persists expanded state across re-renders // dayId -> boolean, persists expanded state across re-renders
const dayExpanded = new Map(); const dayExpanded = new Map();
// cross-file day section navigation (populated by ★ Highlights) // cross-file day section list (populated by ★ Highlights)
let dayActiveSections = []; let dayActiveSections = [];
let dayActiveSectionCursor = -1;
let dayActiveId = null; let dayActiveId = null;
function groupByDay(files) { function groupByDay(files) {
@@ -321,15 +339,79 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
} }
} }
// filename|threshold|gap -> analysis result, so re-renders (filtering, // --- 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.
// clipQueue holds the active review list (one file's sections, or a whole
// day's); J/K and ⏮/⏭ step through it.
let clipQueue = [];
let clipCursor = -1;
function playClip(i) {
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 = document.getElementById('clip-audio');
a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
a.play().catch(() => {});
document.getElementById('clip-label').textContent =
`${i + 1}/${clipQueue.length} · ${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`
+ (c.score != null ? ` · +${Math.round(c.score)} dB` : '');
document.getElementById('clip-bar').hidden = false;
document.body.classList.add('clip-open');
setCutFields(c.fileIdx, c.start, c.end);
announce(`Clip ${i + 1} of ${clipQueue.length}: ${fmtDur(c.start)} to ${fmtDur(c.end)} in ${c.filename}`);
}
function hideClipBar() {
const a = document.getElementById('clip-audio');
a.pause();
a.removeAttribute('src');
document.getElementById('clip-bar').hidden = true;
document.body.classList.remove('clip-open');
clipQueue = [];
clipCursor = -1;
}
function playFileSection(idx, filename, si) {
const secs = sectionMap.get(idx) || [];
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score}));
playClip(si);
}
document.getElementById('clip-prev').addEventListener('click', () => playClip(clipCursor - 1));
document.getElementById('clip-next').addEventListener('click', () => playClip(clipCursor + 1));
document.getElementById('clip-close').addEventListener('click', hideClipBar);
document.getElementById('clip-audio').addEventListener('ended', () => {
if (document.getElementById('clip-auto').checked && clipCursor + 1 < clipQueue.length)
playClip(clipCursor + 1);
});
document.getElementById('clip-context').addEventListener('click', () => {
const c = clipQueue[clipCursor];
if (!c) return;
document.getElementById('clip-audio').pause();
const row = document.getElementById('row-' + c.fileIdx);
if (row) {
const tbl = row.closest('table');
if (tbl && tbl.hidden) // file's day is collapsed: expand it
document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click();
row.scrollIntoView({block: 'center'});
}
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
});
// filename|margin|gap -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has // refresh) never refetch what this session already has
const analysisCache = new Map(); const analysisCache = new Map();
async function fetchAnalysis(filename, threshold, minGap, force = false) { async function fetchAnalysis(filename, margin, minGap, force = false) {
const key = `${filename}|${threshold}|${minGap}`; const key = `${filename}|${margin}|${minGap}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key); if (!force && analysisCache.has(key)) return analysisCache.get(key);
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename) const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&threshold='+encodeURIComponent(threshold) +'&margin='+encodeURIComponent(margin)
+'&min_gap='+encodeURIComponent(minGap)); +'&min_gap='+encodeURIComponent(minGap));
const d = await r.json(); const d = await r.json();
if (!d.error) analysisCache.set(key, d); if (!d.error) analysisCache.set(key, d);
@@ -340,15 +422,15 @@ async function analyse(idx, filename, cell, btn, force = false) {
btn.disabled = true; btn.disabled = true;
btn.textContent = '…'; btn.textContent = '…';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const threshold = document.getElementById('threshold-input').value || '0.05'; const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2'; const minGap = document.getElementById('min-gap-input').value || '2';
const restoreBtn = () => { const restoreBtn = () => {
btn.textContent = 'Analyse'; btn.disabled = false; btn.textContent = 'Analyse'; btn.disabled = false;
btn.onclick = () => analyse(idx, filename, cell, btn); btn.onclick = () => analyse(idx, filename, cell, btn);
if (!cell.contains(btn)) cell.appendChild(btn); if (!cell.contains(btn)) cell.appendChild(btn);
}; };
try { try {
const d = await fetchAnalysis(filename, threshold, minGap, force); const d = await fetchAnalysis(filename, margin, minGap, force);
if (d.error) { if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`; cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
restoreBtn(); return; restoreBtn(); return;
@@ -357,7 +439,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename)); box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
const meta = document.createElement('div'); meta.className='analysis-meta'; const meta = document.createElement('div'); meta.className='analysis-meta';
meta.textContent = `threshold: ${threshold} · gap: ${minGap}s${d.cached ? ' · cached' : ''}`; meta.textContent = `margin: ${margin} dB · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
box.appendChild(meta); box.appendChild(meta);
const chips = document.createElement('div'); const chips = document.createElement('div');
@@ -369,9 +451,10 @@ async function analyse(idx, filename, cell, btn, force = false) {
d.sections.forEach((s, si) => { d.sections.forEach((s, si) => {
const c = document.createElement('button'); const c = document.createElement('button');
c.className='chip'; c.className='chip';
c.title = 'Jump to this section (or use J/K keys)'; c.title = 'Play this section (or use J/K keys)';
c.textContent = `${fmtDur(s.start)} ${fmtDur(s.end)}`; c.textContent = `${fmtDur(s.start)} ${fmtDur(s.end)}`
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si)); + (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
c.addEventListener('click', () => playFileSection(idx, filename, si));
chips.appendChild(c); chips.appendChild(c);
}); });
} else { } else {
@@ -401,21 +484,19 @@ document.addEventListener('keydown', e => {
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return; if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
e.preventDefault(); e.preventDefault();
// Day-level cross-file navigation when Highlights have been loaded // Clip queue navigation (a chip was clicked or day highlights are loaded)
if (dayActiveSections.length) { if (clipQueue.length) {
if (e.key === 'j' || e.key === 'J') { if (e.key === 'j' || e.key === 'J') {
const ni = dayActiveSectionCursor > 0 ? dayActiveSectionCursor - 1 : -1; if (clipCursor > 0) playClip(clipCursor - 1);
if (ni >= 0) jumpToDaySection(ni); else announce('Beginning of sections');
else announce('Beginning of day sections');
} else { } else {
const ni = dayActiveSectionCursor + 1; if (clipCursor + 1 < clipQueue.length) playClip(clipCursor + 1);
if (ni < dayActiveSections.length) jumpToDaySection(ni); else announce('End of sections');
else announce('End of day sections');
} }
return; return;
} }
// Per-file section navigation // Per-file in-player navigation (full-file listening, no clip queue)
if (activePlayerIdx === null) return; if (activePlayerIdx === null) return;
const sections = sectionMap.get(activePlayerIdx) || []; const sections = sectionMap.get(activePlayerIdx) || [];
if (!sections.length) return; if (!sections.length) return;
@@ -494,8 +575,8 @@ async function updateStorage() {
// Auto-loading on a mismatch would silently recompute every file. // Auto-loading on a mismatch would silently recompute every file.
function cachedParamsMatch(ca) { function cachedParamsMatch(ca) {
return ca != null return ca != null
&& Number(ca.threshold) === parseFloat(document.getElementById('threshold-input').value) && Number(ca.margin) === parseFloat(document.getElementById('margin-input').value)
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value); && Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
} }
// Run the deferred analyses of a freshly expanded day // Run the deferred analyses of a freshly expanded day
@@ -734,8 +815,8 @@ function renderFiles(files) {
document.getElementById('dayhl-' + dayId).hidden = true; document.getElementById('dayhl-' + dayId).hidden = true;
if (dayActiveId === dayId) { if (dayActiveId === dayId) {
dayActiveSections = []; dayActiveSections = [];
dayActiveSectionCursor = -1;
dayActiveId = null; dayActiveId = null;
hideClipBar();
} }
} }
}); });
@@ -769,8 +850,8 @@ async function dayHighlights(dayId, analyzableFiles) {
progWrap.appendChild(progBar); progWrap.appendChild(progFile); progWrap.appendChild(progBar); progWrap.appendChild(progFile);
contentEl.innerHTML = ''; contentEl.appendChild(progWrap); contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
const threshold = document.getElementById('threshold-input').value || '0.05'; const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2'; const minGap = document.getElementById('min-gap-input').value || '2';
const results = []; const results = [];
let nCached = 0, nLive = 0; let nCached = 0, nLive = 0;
@@ -779,7 +860,7 @@ async function dayHighlights(dayId, analyzableFiles) {
progFile.textContent = `${i + 1} / ${n} — ${f.name}`; progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`; progFill.style.width = `${(i / n) * 100}%`;
try { try {
const d = await fetchAnalysis(f.name, threshold, minGap); const d = await fetchAnalysis(f.name, margin, minGap);
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; } if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
} catch(e) {} } catch(e) {}
} }
@@ -868,13 +949,16 @@ async function dayHighlights(dayId, analyzableFiles) {
filename: f.name, filename: f.name,
start: s.start, start: s.start,
end: s.end, end: s.end,
score: s.score,
absStart: fileStart + s.start, absStart: fileStart + s.start,
}); });
}); });
}); });
dayActiveSections.sort((a, b) => a.absStart - b.absStart); dayActiveSections.sort((a, b) => a.absStart - b.absStart);
dayActiveSectionCursor = -1;
dayActiveId = dayId; dayActiveId = dayId;
// Arm the clip queue so J/K steps through the day immediately
clipQueue = dayActiveSections;
clipCursor = -1;
const box = document.createElement('div'); const box = document.createElement('div');
box.className = 'wbox'; box.className = 'wbox';
@@ -888,31 +972,37 @@ async function dayHighlights(dayId, analyzableFiles) {
if (dayActiveSections.length) { if (dayActiveSections.length) {
const MAX_DAY_CHIPS = 50; const MAX_DAY_CHIPS = 50;
if (dayActiveSections.length > MAX_DAY_CHIPS) { // 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 = dayActiveSections.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 note = document.createElement('p'); const note = document.createElement('p');
note.className = 'quiet'; note.className = 'quiet';
note.style.marginTop = '6px'; note.style.marginTop = '6px';
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`; note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order`;
box.appendChild(note); box.appendChild(note);
} else {
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
dayActiveSections.forEach((sec, si) => {
const c = document.createElement('button');
c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
const d = new Date(sec.absStart * 1000);
const hms = d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':'
+ d.getSeconds().toString().padStart(2,'0');
c.textContent = hms;
c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c);
});
box.appendChild(chips);
} }
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
chipList.forEach(({sec, si}) => {
const c = document.createElement('button');
c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
const d = new Date(sec.absStart * 1000);
const hms = d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':'
+ d.getSeconds().toString().padStart(2,'0');
c.textContent = hms + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c);
});
box.appendChild(chips);
} }
const summary = document.createElement('div'); const summary = document.createElement('div');
@@ -928,14 +1018,8 @@ async function dayHighlights(dayId, analyzableFiles) {
function jumpToDaySection(si) { function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return; if (si < 0 || si >= dayActiveSections.length) return;
dayActiveSectionCursor = si; clipQueue = dayActiveSections;
const { fileIdx, filename, start, end } = dayActiveSections[si]; playClip(si);
// Close the previous player if switching to a different file
if (activePlayerIdx !== null && activePlayerIdx !== fileIdx) closePlayer(activePlayerIdx);
seekToSection(fileIdx, filename, start, end, null);
announce(`Day section ${si + 1} of ${dayActiveSections.length}: ${fmtDur(start)}${fmtDur(end)} in ${filename}`);
} }
function applyFilters() { function applyFilters() {
@@ -1009,10 +1093,10 @@ document.getElementById('filter-clear').addEventListener('click', () => {
applyFilters(); applyFilters();
}); });
// Seed threshold and min_gap from server config, then start // Seed margin and min_gap from server config, then start
fetch('/api/config').then(r => r.json()).then(cfg => { fetch('/api/config').then(r => r.json()).then(cfg => {
if (cfg.threshold != null) if (cfg.margin != null)
document.getElementById('threshold-input').value = cfg.threshold; document.getElementById('margin-input').value = cfg.margin;
if (cfg.min_gap != null) if (cfg.min_gap != null)
document.getElementById('min-gap-input').value = cfg.min_gap; document.getElementById('min-gap-input').value = cfg.min_gap;
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000))); }).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));