Compare commits
2 Commits
e4d82483b5
...
2caf23f17d
| Author | SHA1 | Date | |
|---|---|---|---|
| 2caf23f17d | |||
| f3716d3ff1 |
@@ -21,7 +21,7 @@ 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, --margin, --min-gap, --analyses-dir)
|
||||
python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --min-duration, --analyses-dir)
|
||||
python -m pytest tests/ # test suite
|
||||
docker compose up -d / down # web UI mapped to host port 8050
|
||||
```
|
||||
@@ -56,9 +56,9 @@ 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`.
|
||||
- **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.
|
||||
- **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. Sections shorter than `min_duration` (default 0.5 s, after `min_gap` merging) are discarded — without this, isolated 100 ms pops (clicks, single raindrops) produced thousands of zero-length sections per day. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. 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`/`--min-duration` → `/api/config` → UI inputs `#margin-input`/`#min-gap-input`/`#min-duration-input` → `/api/analyze` query params → cache JSON head keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` and the `_cached_analysis_params()` regex (see the threshold→margin change `c84b7d8` and the min_duration addition).
|
||||
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so docker-compose layers a read-write `./recordings/analyses` bind mount over it. The `margin`, `min_gap`, and `min_duration` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. Caches written by older detector versions (missing a key) 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).
|
||||
|
||||
@@ -154,6 +154,7 @@ python web.py --dir /path/to/audio # custom recordings directory
|
||||
python web.py --port 8888 # custom port
|
||||
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-duration 1 # discard loud sections shorter than this many seconds (default 0.5)
|
||||
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
|
||||
```
|
||||
|
||||
@@ -162,14 +163,15 @@ 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. 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 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.
|
||||
- **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 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, min-gap, and min-duration 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.
|
||||
- **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).
|
||||
- **Min duration** — configurable in the controls bar (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable.
|
||||
- **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 the **Prev** / **Next** buttons) 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.
|
||||
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
|
||||
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` in the table (header is unfinalized until recording stops). The file list refreshes automatically when a recording starts, stops, or rolls over to a new split file (unless audio is playing).
|
||||
- **Listen while recording** — in-progress files are playable and seekable. For WAV and FLAC the server patches the (still unfinalized) header on the fly so the browser sees the real duration-so-far — for FLAC the exact sample count is parsed from the last frame header in the file tail. Reopening the player reloads the source to pick up newly recorded audio. Live responses are sent with `Cache-Control: no-store`.
|
||||
- **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.
|
||||
@@ -182,14 +184,14 @@ Everything the UI does goes through these endpoints, so they can also be scripte
|
||||
| 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/analyze?file=&margin=&min_gap=&min_duration=` | 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) |
|
||||
| `GET /api/config` | Server-side defaults for margin, min-gap, and min-duration (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.
|
||||
|
||||
+34
-2
@@ -7,9 +7,9 @@ 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):
|
||||
def _run(rms, margin_db=12.0, min_gap=2.0, min_duration=0.5):
|
||||
duration = len(rms) * WINDOW_DUR
|
||||
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap)
|
||||
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
|
||||
|
||||
|
||||
def test_burst_above_quiet_floor_is_detected():
|
||||
@@ -60,6 +60,38 @@ def test_min_gap_merges_nearby_bursts():
|
||||
assert sections[1]['start'] == 90.0
|
||||
|
||||
|
||||
def test_min_duration_drops_subsecond_blips():
|
||||
# Isolated single-window pops (clicks, single raindrops) spaced wider than
|
||||
# min_gap must not each become their own section — this is what used to
|
||||
# produce thousands of zero-length sections per day.
|
||||
rms = [0.002] * 1200
|
||||
for i in range(600, 660, 30): # 0.1 s blips, 3 s apart (> min_gap)
|
||||
rms[i] = 0.05
|
||||
assert _run(rms) == []
|
||||
# With the filter disabled they are all reported
|
||||
assert len(_run(rms, min_duration=0.0)) == 2
|
||||
|
||||
|
||||
def test_min_duration_keeps_sections_at_or_above_it():
|
||||
rms = [0.002] * 1200
|
||||
rms[600:605] = [0.05] * 5 # exactly 0.5 s
|
||||
sections = _run(rms, min_duration=0.5)
|
||||
assert len(sections) == 1
|
||||
assert sections[0]['start'] == 60.0
|
||||
|
||||
|
||||
def test_min_duration_applies_after_gap_merging():
|
||||
# Two sub-min_duration blips within min_gap merge into one section whose
|
||||
# loud span exceeds min_duration — the merged section must survive.
|
||||
rms = [0.002] * 1200
|
||||
rms[600] = 0.05
|
||||
rms[610] = 0.05 # 1 s apart < 2 s min_gap → merged, 1.1 s span
|
||||
sections = _run(rms, min_duration=1.0)
|
||||
assert len(sections) == 1
|
||||
assert sections[0]['start'] == 60.0
|
||||
assert sections[0]['end'] >= 61.0
|
||||
|
||||
|
||||
def test_noise_floor_tracks_blocks_and_ignores_short_events():
|
||||
quiet_db = 20 * math.log10(0.002)
|
||||
db = [quiet_db] * 1200
|
||||
|
||||
@@ -49,6 +49,7 @@ AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
|
||||
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
|
||||
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_DURATION_SECONDS = 0.5 # discard loud sections shorter 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
|
||||
@@ -292,10 +293,16 @@ def _noise_floor_db(db_values: list, window_dur: float) -> list:
|
||||
|
||||
|
||||
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||
margin_db: float, min_gap: float = MIN_GAP_SECONDS) -> list:
|
||||
margin_db: float, min_gap: float = MIN_GAP_SECONDS,
|
||||
min_duration: float = MIN_DURATION_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."""
|
||||
by the UI to rank sections by how much they stand out.
|
||||
|
||||
Sections shorter than min_duration (after min_gap merging) are discarded:
|
||||
without this, every isolated 100 ms window that pops above the floor — a
|
||||
click, a single raindrop — becomes its own section and a day can drown in
|
||||
thousands of sub-second clips."""
|
||||
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)
|
||||
@@ -316,13 +323,15 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||
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),
|
||||
'score': round(peak, 1)})
|
||||
end_t = last_loud_t + window_dur
|
||||
if end_t - start_t >= min_duration - 1e-9:
|
||||
sections.append({'start': round(start_t, 1),
|
||||
'end': round(end_t, 1),
|
||||
'score': round(peak, 1)})
|
||||
start_t = None
|
||||
last_loud_t = None
|
||||
|
||||
if start_t is not None:
|
||||
if start_t is not None and (last_loud_t + window_dur - start_t) >= min_duration - 1e-9:
|
||||
sections.append({'start': round(start_t, 1), 'end': round(duration, 1),
|
||||
'score': round(peak, 1)})
|
||||
|
||||
@@ -331,7 +340,8 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||
|
||||
def _package_result(rms_values: list, framerate: int, n_frames: int,
|
||||
window_samples: int, margin_db: float,
|
||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||
min_gap: float = MIN_GAP_SECONDS,
|
||||
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
||||
window_dur = window_samples / framerate
|
||||
duration = n_frames / framerate
|
||||
|
||||
@@ -345,7 +355,7 @@ 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, margin_db, min_gap),
|
||||
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
|
||||
'duration': round(duration, 2),
|
||||
'window': round(window_dur, 4),
|
||||
}
|
||||
@@ -353,7 +363,8 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
|
||||
|
||||
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||||
margin_db: float = MARGIN_DB,
|
||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||
min_gap: float = MIN_GAP_SECONDS,
|
||||
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
||||
try:
|
||||
with wave.open(str(path), 'rb') as wf:
|
||||
channels = wf.getnchannels()
|
||||
@@ -365,12 +376,13 @@ 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, margin_db, min_gap)
|
||||
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
|
||||
|
||||
|
||||
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||||
margin_db: float = MARGIN_DB,
|
||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||
min_gap: float = MIN_GAP_SECONDS,
|
||||
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
||||
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
|
||||
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
|
||||
return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
|
||||
@@ -392,7 +404,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, margin_db, min_gap)
|
||||
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -405,19 +417,21 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
|
||||
|
||||
|
||||
def _cached_analysis_params(cache_path: Path):
|
||||
"""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. Caches written by the old
|
||||
fixed-threshold detector have no margin key and simply never match."""
|
||||
"""Read just margin/min_gap/min_duration 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 three keys first. Caches written by
|
||||
older detector versions lack one of the keys 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'"margin":\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+-]+),'
|
||||
r'\s*"min_duration":\s*([0-9.eE+-]+)', head)
|
||||
if not m:
|
||||
return None
|
||||
return {'margin': float(m.group(1)), 'min_gap': float(m.group(2))}
|
||||
return {'margin': float(m.group(1)), 'min_gap': float(m.group(2)),
|
||||
'min_duration': float(m.group(3))}
|
||||
|
||||
|
||||
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
||||
@@ -518,6 +532,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
analyses_dir: str = 'recordings/analyses'
|
||||
margin_db: float = MARGIN_DB
|
||||
min_gap: float = MIN_GAP_SECONDS
|
||||
min_duration: float = MIN_DURATION_SECONDS
|
||||
|
||||
def do_DELETE(self):
|
||||
parsed = urlparse(self.path)
|
||||
@@ -593,6 +608,12 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
except (ValueError, TypeError):
|
||||
min_gap = self.min_gap
|
||||
|
||||
try:
|
||||
min_duration = float(qs.get('min_duration', [self.min_duration])[0])
|
||||
min_duration = max(0.0, min(60.0, min_duration))
|
||||
except (ValueError, TypeError):
|
||||
min_duration = self.min_duration
|
||||
|
||||
if self._is_active(filename):
|
||||
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
|
||||
return
|
||||
@@ -602,7 +623,8 @@ 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('margin') == margin and cached.get('min_gap') == min_gap:
|
||||
if (cached.get('margin') == margin and cached.get('min_gap') == min_gap
|
||||
and cached.get('min_duration') == min_duration):
|
||||
payload = dict(cached['result'])
|
||||
payload.pop('rms', None) # caches written before the full-RMS field was dropped
|
||||
payload['cached'] = True
|
||||
@@ -613,12 +635,12 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
|
||||
ext = path.suffix.lower()
|
||||
if ext == '.wav':
|
||||
result = analyze_wav(path, margin_db=margin, min_gap=min_gap)
|
||||
result = analyze_wav(path, margin_db=margin, min_gap=min_gap, min_duration=min_duration)
|
||||
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, margin_db=margin, min_gap=min_gap)
|
||||
result = analyze_flac(path, margin_db=margin, min_gap=min_gap, min_duration=min_duration)
|
||||
else:
|
||||
self._json_err(400, f'Loudness analysis is not available for {ext} files')
|
||||
return
|
||||
@@ -626,9 +648,10 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cache_path.with_suffix('.tmp')
|
||||
# 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')
|
||||
# margin, min_gap and min_duration 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,
|
||||
'min_duration': min_duration, '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)
|
||||
@@ -745,7 +768,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send(200, data.encode(), 'application/json')
|
||||
|
||||
def _api_config(self):
|
||||
data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap})
|
||||
data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap,
|
||||
'min_duration': self.min_duration})
|
||||
self._send(200, data.encode(), 'application/json')
|
||||
|
||||
def _api_delete(self, filename: str):
|
||||
@@ -1041,6 +1065,9 @@ def main():
|
||||
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('--min-duration', type=float, default=MIN_DURATION_SECONDS,
|
||||
help=f'Discard loud sections shorter than this many seconds '
|
||||
f'(default: {MIN_DURATION_SECONDS})')
|
||||
parser.add_argument('--analyses-dir', default=None,
|
||||
help='Directory for analysis cache files (default: <recordings-dir>/analyses)')
|
||||
args = parser.parse_args()
|
||||
@@ -1059,6 +1086,7 @@ def main():
|
||||
analyses_dir = str(_analyses_dir)
|
||||
margin_db = args.margin
|
||||
min_gap = args.min_gap
|
||||
min_duration = args.min_duration
|
||||
|
||||
server = _Server((args.host, args.port), Handler)
|
||||
|
||||
|
||||
+39
-29
@@ -140,7 +140,7 @@ body.clip-open{padding-bottom:70px}
|
||||
<h1>ISR Archive</h1>
|
||||
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
|
||||
<span id="storage-info" aria-live="polite"></span>
|
||||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||||
<button id="refresh-btn" aria-label="Refresh file list">Refresh</button>
|
||||
</header>
|
||||
<div class="controls-bar">
|
||||
<label for="margin-input">Loudness margin:</label>
|
||||
@@ -155,6 +155,10 @@ body.clip-open{padding-bottom:70px}
|
||||
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
|
||||
aria-describedby="min-gap-hint">
|
||||
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
|
||||
<label for="min-duration-input" style="margin-left:16px">Min duration:</label>
|
||||
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" value="0.5"
|
||||
aria-describedby="min-duration-hint">
|
||||
<span id="min-duration-hint" class="controls-hint">seconds — ignore loud sections shorter than this</span>
|
||||
</div>
|
||||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||
<label for="filter-name">Search:</label>
|
||||
@@ -163,20 +167,20 @@ body.clip-open{padding-bottom:70px}
|
||||
<input type="date" id="filter-from" aria-label="From date">
|
||||
<label for="filter-to">To:</label>
|
||||
<input type="date" id="filter-to" aria-label="To date">
|
||||
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||||
<button id="filter-clear" aria-label="Clear all filters">Clear</button>
|
||||
</div>
|
||||
<div class="wrap" id="main">
|
||||
<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>
|
||||
<button id="clip-prev" aria-label="Previous section (J)">Prev</button>
|
||||
<button id="clip-next" aria-label="Next section (K)">Next</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>
|
||||
<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)
|
||||
@@ -222,7 +226,7 @@ let activePlayerIdx = null;
|
||||
let allFiles = [];
|
||||
// dayId -> boolean, persists expanded state across re-renders
|
||||
const dayExpanded = new Map();
|
||||
// cross-file day section list (populated by ★ Highlights)
|
||||
// cross-file day section list (populated by the day Highlights button)
|
||||
let dayActiveSections = [];
|
||||
let dayActiveId = null;
|
||||
|
||||
@@ -244,7 +248,7 @@ function closePlayer(idx) {
|
||||
const btn = document.getElementById('pbtn-'+idx);
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded','false');
|
||||
btn.textContent = '▶ Play';
|
||||
btn.textContent = 'Play';
|
||||
btn.setAttribute('aria-label','Play '+(recMap.get(idx) || ''));
|
||||
}
|
||||
}
|
||||
@@ -272,7 +276,7 @@ function togglePlayer(idx, filename) {
|
||||
activePlayerIdx = idx;
|
||||
prow.hidden = false;
|
||||
btn.setAttribute('aria-expanded','true');
|
||||
btn.textContent = '⏹ Hide';
|
||||
btn.textContent = 'Hide';
|
||||
btn.setAttribute('aria-label','Hide player for '+filename);
|
||||
audio.focus();
|
||||
}
|
||||
@@ -343,7 +347,7 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
// 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.
|
||||
// day's); J/K and the Prev/Next buttons step through it.
|
||||
let clipQueue = [];
|
||||
let clipCursor = -1;
|
||||
|
||||
@@ -403,16 +407,17 @@ document.getElementById('clip-context').addEventListener('click', () => {
|
||||
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
|
||||
});
|
||||
|
||||
// filename|margin|gap -> analysis result, so re-renders (filtering,
|
||||
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
|
||||
// refresh) never refetch what this session already has
|
||||
const analysisCache = new Map();
|
||||
|
||||
async function fetchAnalysis(filename, margin, minGap, force = false) {
|
||||
const key = `${filename}|${margin}|${minGap}`;
|
||||
async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
|
||||
const key = `${filename}|${margin}|${minGap}|${minDur}`;
|
||||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||||
+'&margin='+encodeURIComponent(margin)
|
||||
+'&min_gap='+encodeURIComponent(minGap));
|
||||
+'&min_gap='+encodeURIComponent(minGap)
|
||||
+'&min_duration='+encodeURIComponent(minDur));
|
||||
const d = await r.json();
|
||||
if (!d.error) analysisCache.set(key, d);
|
||||
return d;
|
||||
@@ -424,13 +429,14 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||||
const margin = document.getElementById('margin-input').value || '12';
|
||||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||
const minDur = document.getElementById('min-duration-input').value || '0.5';
|
||||
const restoreBtn = () => {
|
||||
btn.textContent = 'Analyse'; btn.disabled = false;
|
||||
btn.onclick = () => analyse(idx, filename, cell, btn);
|
||||
if (!cell.contains(btn)) cell.appendChild(btn);
|
||||
};
|
||||
try {
|
||||
const d = await fetchAnalysis(filename, margin, minGap, force);
|
||||
const d = await fetchAnalysis(filename, margin, minGap, minDur, force);
|
||||
if (d.error) {
|
||||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||||
restoreBtn(); return;
|
||||
@@ -439,7 +445,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 = `margin: ${margin} dB · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
|
||||
meta.textContent = `margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}s${d.cached ? ' · cached' : ''}`;
|
||||
box.appendChild(meta);
|
||||
|
||||
const chips = document.createElement('div');
|
||||
@@ -551,11 +557,11 @@ async function deleteFile(idx, filename) {
|
||||
} else {
|
||||
const d = await r.json().catch(()=>({}));
|
||||
alert('Delete failed: '+(d.error||r.statusText));
|
||||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Delete failed: '+e.message);
|
||||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +582,8 @@ async function updateStorage() {
|
||||
function cachedParamsMatch(ca) {
|
||||
return ca != null
|
||||
&& 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)
|
||||
&& Number(ca.min_duration) === parseFloat(document.getElementById('min-duration-input').value);
|
||||
}
|
||||
|
||||
// Run the deferred analyses of a freshly expanded day
|
||||
@@ -694,13 +701,13 @@ function renderFiles(files) {
|
||||
<button class="day-toggle" id="daytgl-${dayId}"
|
||||
aria-expanded="${expanded}"
|
||||
aria-controls="daytbl-${dayId}">
|
||||
<span class="day-arrow" aria-hidden="true">${expanded ? '▼' : '▶'}</span>
|
||||
<span class="day-arrow" aria-hidden="true">${expanded ? '▾' : '▸'}</span>
|
||||
${esc(day)}
|
||||
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
|
||||
</button>
|
||||
</h2>
|
||||
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
|
||||
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}`;
|
||||
aria-label="Show day highlights for ${esc(day)}">Highlights</button>` : ''}`;
|
||||
section.appendChild(headBar);
|
||||
|
||||
// Highlights panel (hidden until button clicked)
|
||||
@@ -759,15 +766,15 @@ function renderFiles(files) {
|
||||
<button id="pbtn-${i}"
|
||||
aria-expanded="false"
|
||||
aria-controls="prow-${i}"
|
||||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||||
aria-label="Play ${esc(f.name)}">Play</button>
|
||||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||||
aria-label="Download ${esc(f.name)}">Download</a>
|
||||
<button id="cutbtn-${i}" class="cut"
|
||||
aria-label="Cut ${esc(f.name)}"
|
||||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
||||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>Cut</button>
|
||||
<button id="delbtn-${i}" class="del"
|
||||
aria-label="Delete ${esc(f.name)}"
|
||||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>Delete</button>
|
||||
</div>
|
||||
</td>`;
|
||||
dayTbody.appendChild(tr);
|
||||
@@ -783,7 +790,7 @@ function renderFiles(files) {
|
||||
<audio id="aud-${i}" controls preload="none"
|
||||
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||||
<div class="cut-panel">
|
||||
<span class="cut-label">✂ Cut:</span>
|
||||
<span class="cut-label">Cut:</span>
|
||||
<label class="cut-field">Start
|
||||
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||||
</label>
|
||||
@@ -792,7 +799,7 @@ function renderFiles(files) {
|
||||
</label>
|
||||
<button id="cut-dl-${i}" class="cut"
|
||||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
||||
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
||||
aria-label="Download cut of ${esc(f.name)}">Download cut</button>
|
||||
</div>
|
||||
</td>`;
|
||||
dayTbody.appendChild(prow);
|
||||
@@ -806,7 +813,7 @@ function renderFiles(files) {
|
||||
dayExpanded.set(dayId, nowExp);
|
||||
const tgl = document.getElementById('daytgl-' + dayId);
|
||||
tgl.setAttribute('aria-expanded', nowExp);
|
||||
tgl.querySelector('.day-arrow').textContent = nowExp ? '▼' : '▶';
|
||||
tgl.querySelector('.day-arrow').textContent = nowExp ? '▾' : '▸';
|
||||
headBar.classList.toggle('open', nowExp);
|
||||
document.getElementById('daytbl-' + dayId).hidden = !nowExp;
|
||||
if (nowExp) autoloadDayAnalyses(dayId);
|
||||
@@ -852,6 +859,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
|
||||
const margin = document.getElementById('margin-input').value || '12';
|
||||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||
const minDur = document.getElementById('min-duration-input').value || '0.5';
|
||||
|
||||
const results = [];
|
||||
let nCached = 0, nLive = 0;
|
||||
@@ -860,7 +868,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, margin, minGap);
|
||||
const d = await fetchAnalysis(f.name, margin, minGap, minDur);
|
||||
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||||
} catch(e) {}
|
||||
}
|
||||
@@ -1099,6 +1107,8 @@ fetch('/api/config').then(r => r.json()).then(cfg => {
|
||||
document.getElementById('margin-input').value = cfg.margin;
|
||||
if (cfg.min_gap != null)
|
||||
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||||
if (cfg.min_duration != null)
|
||||
document.getElementById('min-duration-input').value = cfg.min_duration;
|
||||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user