Compare commits
3 Commits
16dd7cbe51
...
e4d82483b5
| Author | SHA1 | Date | |
|---|---|---|---|
| e4d82483b5 | |||
| 119e631faf | |||
| c84b7d8222 |
@@ -5,7 +5,7 @@ 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` (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
|
||||
|
||||
@@ -21,12 +21,33 @@ Guidance for Claude Code when working in this repository.
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
- Auto-reconnect on stream drops or device errors
|
||||
- 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
|
||||
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
|
||||
# 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 --dir /path/to/audio # custom recordings directory
|
||||
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 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 --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:
|
||||
|
||||
- **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.
|
||||
- **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 15–30 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).
|
||||
- **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.
|
||||
@@ -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.
|
||||
- **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
|
||||
@@ -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).
|
||||
|
||||
**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:
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
@@ -3,13 +3,14 @@
|
||||
ISR Web — Browse and download recorded audio files.
|
||||
|
||||
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:
|
||||
python web.py # serves recordings/ on port 8080
|
||||
python web.py --dir /path/to/audio # custom recordings directory
|
||||
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
|
||||
@@ -46,9 +47,16 @@ except ImportError:
|
||||
|
||||
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
|
||||
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
|
||||
LOUD_THRESHOLD = 0.05 # RMS 0–1 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
|
||||
|
||||
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 = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
@@ -211,6 +219,17 @@ def _live_flac_header(path: Path, size: int):
|
||||
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):
|
||||
"""Return duration in seconds for any supported audio file, or None."""
|
||||
ext = path.suffix.lower()
|
||||
@@ -254,33 +273,64 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
|
||||
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,
|
||||
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 = []
|
||||
start_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
|
||||
if rms >= threshold:
|
||||
floor_eff = max(floor[i], min_db)
|
||||
if d >= floor_eff + margin_db:
|
||||
if start_t is None:
|
||||
start_t = t
|
||||
peak = 0.0
|
||||
last_loud_t = t
|
||||
peak = max(peak, d - floor_eff)
|
||||
else:
|
||||
if start_t is not None and (t - last_loud_t) > min_gap:
|
||||
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
|
||||
last_loud_t = 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
|
||||
|
||||
|
||||
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:
|
||||
window_dur = window_samples / 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.
|
||||
return {
|
||||
'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),
|
||||
'window': round(window_dur, 4),
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
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,
|
||||
threshold: float = LOUD_THRESHOLD,
|
||||
margin_db: float = MARGIN_DB,
|
||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
|
||||
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:
|
||||
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):
|
||||
"""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
|
||||
_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:
|
||||
with open(cache_path, 'r', encoding='utf-8') as fh:
|
||||
head = fh.read(256)
|
||||
except OSError:
|
||||
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:
|
||||
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):
|
||||
@@ -465,7 +516,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
|
||||
recordings_dir: str = 'recordings'
|
||||
analyses_dir: str = 'recordings/analyses'
|
||||
threshold: float = LOUD_THRESHOLD
|
||||
margin_db: float = MARGIN_DB
|
||||
min_gap: float = MIN_GAP_SECONDS
|
||||
|
||||
def do_DELETE(self):
|
||||
@@ -495,6 +546,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._api_config()
|
||||
elif p == '/api/cut':
|
||||
self._api_cut(qs)
|
||||
elif p == '/api/clip':
|
||||
self._api_clip(qs)
|
||||
elif p.startswith('/download/'):
|
||||
self._download(unquote(p[len('/download/'):]))
|
||||
elif p.startswith('/stream/'):
|
||||
@@ -529,10 +582,10 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
try:
|
||||
threshold = float(qs.get('threshold', [self.threshold])[0])
|
||||
threshold = max(0.0, min(1.0, threshold))
|
||||
margin = float(qs.get('margin', [self.margin_db])[0])
|
||||
margin = max(1.0, min(60.0, margin))
|
||||
except (ValueError, TypeError):
|
||||
threshold = self.threshold
|
||||
margin = self.margin_db
|
||||
|
||||
try:
|
||||
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)
|
||||
try:
|
||||
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.pop('rms', None) # caches written before the full-RMS field was dropped
|
||||
payload['cached'] = True
|
||||
@@ -560,12 +613,12 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
|
||||
ext = path.suffix.lower()
|
||||
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':
|
||||
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
|
||||
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
|
||||
return
|
||||
result = analyze_flac(path, threshold=threshold, min_gap=min_gap)
|
||||
result = analyze_flac(path, margin_db=margin, min_gap=min_gap)
|
||||
else:
|
||||
self._json_err(400, f'Loudness analysis is not available for {ext} files')
|
||||
return
|
||||
@@ -573,7 +626,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
except Exception as e:
|
||||
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')
|
||||
|
||||
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')
|
||||
|
||||
def _api_delete(self, filename: str):
|
||||
@@ -790,6 +845,114 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
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:
|
||||
"""True if isr.py reports this file as currently being recorded."""
|
||||
try:
|
||||
@@ -873,8 +1036,9 @@ def main():
|
||||
help='HTTP port (default: 8080)')
|
||||
parser.add_argument('--host', default='0.0.0.0',
|
||||
help='Bind address (default: 0.0.0.0)')
|
||||
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD,
|
||||
help=f'RMS loudness threshold 0–1 (default: {LOUD_THRESHOLD})')
|
||||
parser.add_argument('--margin', type=float, default=MARGIN_DB,
|
||||
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,
|
||||
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
||||
parser.add_argument('--analyses-dir', default=None,
|
||||
@@ -893,7 +1057,7 @@ def main():
|
||||
class Handler(_Handler):
|
||||
recordings_dir = str(rec_dir)
|
||||
analyses_dir = str(_analyses_dir)
|
||||
threshold = args.threshold
|
||||
margin_db = args.margin
|
||||
min_gap = args.min_gap
|
||||
|
||||
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"Recordings dir: {rec_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:
|
||||
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
|
||||
elif not SOUNDFILE_AVAILABLE:
|
||||
|
||||
+131
-47
@@ -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}
|
||||
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -133,10 +143,10 @@ svg.day-timeline{display:block;width:100%;height:22px}
|
||||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||||
</header>
|
||||
<div class="controls-bar">
|
||||
<label for="threshold-input">Analysis threshold:</label>
|
||||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||||
aria-describedby="threshold-hint">
|
||||
<span id="threshold-hint" class="controls-hint">RMS amplitude 0–1 (linear; 0.05 ≈ −26 dBFS) · sections above this are marked loud</span>
|
||||
<label for="margin-input">Loudness margin:</label>
|
||||
<input type="number" id="margin-input" min="1" max="60" step="1" value="12"
|
||||
aria-describedby="margin-hint">
|
||||
<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>
|
||||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||||
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="empty" class="empty" style="display:none" role="status">No recordings found.</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>
|
||||
const esc = s => String(s)
|
||||
.replace(/&/g,'&').replace(/</g,'<')
|
||||
@@ -203,9 +222,8 @@ let activePlayerIdx = null;
|
||||
let allFiles = [];
|
||||
// dayId -> boolean, persists expanded state across re-renders
|
||||
const dayExpanded = new Map();
|
||||
// cross-file day section navigation (populated by ★ Highlights)
|
||||
// cross-file day section list (populated by ★ Highlights)
|
||||
let dayActiveSections = [];
|
||||
let dayActiveSectionCursor = -1;
|
||||
let dayActiveId = null;
|
||||
|
||||
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
|
||||
const analysisCache = new Map();
|
||||
|
||||
async function fetchAnalysis(filename, threshold, minGap, force = false) {
|
||||
const key = `${filename}|${threshold}|${minGap}`;
|
||||
async function fetchAnalysis(filename, margin, minGap, force = false) {
|
||||
const key = `${filename}|${margin}|${minGap}`;
|
||||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||||
+'&threshold='+encodeURIComponent(threshold)
|
||||
+'&margin='+encodeURIComponent(margin)
|
||||
+'&min_gap='+encodeURIComponent(minGap));
|
||||
const d = await r.json();
|
||||
if (!d.error) analysisCache.set(key, d);
|
||||
@@ -340,7 +422,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
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 restoreBtn = () => {
|
||||
btn.textContent = 'Analyse'; btn.disabled = false;
|
||||
@@ -348,7 +430,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
if (!cell.contains(btn)) cell.appendChild(btn);
|
||||
};
|
||||
try {
|
||||
const d = await fetchAnalysis(filename, threshold, minGap, force);
|
||||
const d = await fetchAnalysis(filename, margin, minGap, force);
|
||||
if (d.error) {
|
||||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
const chips = document.createElement('div');
|
||||
@@ -369,9 +451,10 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
d.sections.forEach((s, si) => {
|
||||
const c = document.createElement('button');
|
||||
c.className='chip';
|
||||
c.title = 'Jump to this section (or use J/K keys)';
|
||||
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`;
|
||||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||||
c.title = 'Play this section (or use J/K keys)';
|
||||
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`
|
||||
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||||
c.addEventListener('click', () => playFileSection(idx, filename, si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
} else {
|
||||
@@ -401,21 +484,19 @@ document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
|
||||
e.preventDefault();
|
||||
|
||||
// Day-level cross-file navigation when Highlights have been loaded
|
||||
if (dayActiveSections.length) {
|
||||
// Clip queue navigation (a chip was clicked or day highlights are loaded)
|
||||
if (clipQueue.length) {
|
||||
if (e.key === 'j' || e.key === 'J') {
|
||||
const ni = dayActiveSectionCursor > 0 ? dayActiveSectionCursor - 1 : -1;
|
||||
if (ni >= 0) jumpToDaySection(ni);
|
||||
else announce('Beginning of day sections');
|
||||
if (clipCursor > 0) playClip(clipCursor - 1);
|
||||
else announce('Beginning of sections');
|
||||
} else {
|
||||
const ni = dayActiveSectionCursor + 1;
|
||||
if (ni < dayActiveSections.length) jumpToDaySection(ni);
|
||||
else announce('End of day sections');
|
||||
if (clipCursor + 1 < clipQueue.length) playClip(clipCursor + 1);
|
||||
else announce('End of sections');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-file section navigation
|
||||
// Per-file in-player navigation (full-file listening, no clip queue)
|
||||
if (activePlayerIdx === null) return;
|
||||
const sections = sectionMap.get(activePlayerIdx) || [];
|
||||
if (!sections.length) return;
|
||||
@@ -494,7 +575,7 @@ async function updateStorage() {
|
||||
// Auto-loading on a mismatch would silently recompute every file.
|
||||
function cachedParamsMatch(ca) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -734,8 +815,8 @@ function renderFiles(files) {
|
||||
document.getElementById('dayhl-' + dayId).hidden = true;
|
||||
if (dayActiveId === dayId) {
|
||||
dayActiveSections = [];
|
||||
dayActiveSectionCursor = -1;
|
||||
dayActiveId = null;
|
||||
hideClipBar();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -769,7 +850,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
progWrap.appendChild(progBar); progWrap.appendChild(progFile);
|
||||
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 results = [];
|
||||
@@ -779,7 +860,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||||
progFill.style.width = `${(i / n) * 100}%`;
|
||||
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++; }
|
||||
} catch(e) {}
|
||||
}
|
||||
@@ -868,13 +949,16 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
filename: f.name,
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
score: s.score,
|
||||
absStart: fileStart + s.start,
|
||||
});
|
||||
});
|
||||
});
|
||||
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
|
||||
dayActiveSectionCursor = -1;
|
||||
dayActiveId = dayId;
|
||||
// Arm the clip queue so J/K steps through the day immediately
|
||||
clipQueue = dayActiveSections;
|
||||
clipCursor = -1;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
@@ -888,18 +972,25 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
|
||||
if (dayActiveSections.length) {
|
||||
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');
|
||||
note.className = 'quiet';
|
||||
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);
|
||||
} 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) => {
|
||||
chipList.forEach(({sec, si}) => {
|
||||
const c = document.createElement('button');
|
||||
c.className = 'chip';
|
||||
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
||||
@@ -907,13 +998,12 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
||||
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
||||
+ d.getSeconds().toString().padStart(2,'0');
|
||||
c.textContent = hms;
|
||||
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');
|
||||
summary.className = 'quiet';
|
||||
@@ -928,14 +1018,8 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
|
||||
function jumpToDaySection(si) {
|
||||
if (si < 0 || si >= dayActiveSections.length) return;
|
||||
dayActiveSectionCursor = si;
|
||||
const { fileIdx, filename, start, end } = dayActiveSections[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}`);
|
||||
clipQueue = dayActiveSections;
|
||||
playClip(si);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
@@ -1009,10 +1093,10 @@ document.getElementById('filter-clear').addEventListener('click', () => {
|
||||
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 => {
|
||||
if (cfg.threshold != null)
|
||||
document.getElementById('threshold-input').value = cfg.threshold;
|
||||
if (cfg.margin != null)
|
||||
document.getElementById('margin-input').value = cfg.margin;
|
||||
if (cfg.min_gap != null)
|
||||
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||
|
||||
Reference in New Issue
Block a user