feat: adaptive noise-floor loudness detection with section scoring

Replace the fixed RMS threshold with prominence over a rolling noise
floor (20th percentile per 30s block, min-smoothed so events cannot
raise their own floor, clamped to -54 dBFS). Slow ambience changes such
as rain or daytime traffic hum move the floor instead of flagging
everything; sections now need `margin` dB (default 12) of prominence.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:36:48 +02:00
parent 16dd7cbe51
commit c84b7d8222
5 changed files with 197 additions and 78 deletions
+4 -3
View File
@@ -5,7 +5,7 @@ Guidance for Claude Code when working in this repository.
## Rules ## Rules
- **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals. - **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals.
- Run `python -m pytest tests/` after changing `isr.py` (tests cover the recorder only). - Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector).
## Files ## Files
@@ -21,7 +21,7 @@ Guidance for Claude Code when working in this repository.
```bash ```bash
python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
python web.py # web UI on :8080 (--dir, --port, --threshold, --min-gap, --analyses-dir) python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --analyses-dir)
python -m pytest tests/ # test suite python -m pytest tests/ # test suite
docker compose up -d / down # web UI mapped to host port 8050 docker compose up -d / down # web UI mapped to host port 8050
``` ```
@@ -35,7 +35,8 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC/
- **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour. - **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
- **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`). - **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`).
- **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`. - **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by threshold+min_gap; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so the cache uses a separate `./analyses` bind mount. The `threshold` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. - **Loud-section detection is adaptive:** 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. Tests in `tests/test_web.py`.
- **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 the cache uses a separate `./analyses` bind mount. The `margin` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. Old `threshold`-keyed caches never match and get overwritten on the next analyse.
- **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger. - **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger.
- **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve). - **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve).
- **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC. - **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
+3 -3
View File
@@ -150,7 +150,7 @@ strftime codes are substituted at split time. The file extension is added automa
python web.py # serves ./recordings on port 8080 python web.py # serves ./recordings on port 8080
python web.py --dir /path/to/audio # custom recordings directory python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold 01 (default 0.05) python web.py --margin 15 # dB above background noise for a section to count as loud (default 12)
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2) python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses) python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
``` ```
@@ -160,9 +160,9 @@ The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at star
Shows recordings grouped by day with collapsible sections. Features: Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes. - **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
- **Day highlights** — click **★ Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. - **Day highlights** — click **★ Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. When a day has more sections than fit as chips, the chips show the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; J/K still steps through all sections in time order.
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play. - **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same threshold and min-gap settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup. - **Waveform analysis** — on demand per file; computes RMS per 100 ms window and marks sections that stand out above the background. Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** (its peak dB above the floor) used to rank sections by how much they stand out. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin and min-gap settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it. - **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it.
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing. - **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.
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). - **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the adaptive loud-section detector in web.py."""
import math
from web import _loud_sections, _noise_floor_db
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
def _run(rms, margin_db=12.0, min_gap=2.0):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap)
def test_burst_above_quiet_floor_is_detected():
rms = [0.002] * 1200 # 2 min of quiet ambience (54 dBFS)
rms[600:610] = [0.05] * 10 # 1 s burst at 26 dBFS (+28 dB)
sections = _run(rms)
assert len(sections) == 1
s = sections[0]
assert s['start'] == 60.0
assert 60.5 <= s['end'] <= 62.0
assert 26.0 <= s['score'] <= 30.0
def test_slow_ambience_swell_is_not_detected():
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
# The old fixed threshold would have flagged the entire loud half.
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # 54 → 34 dB
down = list(reversed(up))
assert _run(up + down) == []
def test_burst_still_detected_on_loud_ambience():
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
rms = [0.02] * 1200
rms[600:610] = [0.5] * 10
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_rms_floor_suppresses_blips_in_digital_silence():
rms = [0.000001] * 1200
rms[600:605] = [0.004] * 5 # 48 dBFS: audible blip, but below MIN_RMS+12dB
assert _run(rms) == []
rms[600:605] = [0.05] * 5 # 26 dBFS clearly clears the clamped floor
assert len(_run(rms)) == 1
def test_min_gap_merges_nearby_bursts():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
rms[900:905] = [0.05] * 5 # 28 s away → separate section
sections = _run(rms)
assert len(sections) == 2
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.5
assert sections[1]['start'] == 90.0
def test_noise_floor_tracks_blocks_and_ignores_short_events():
quiet_db = 20 * math.log10(0.002)
db = [quiet_db] * 1200
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
floor = _noise_floor_db(db, WINDOW_DUR)
assert len(floor) == len(db)
assert all(abs(f - quiet_db) < 1.0 for f in floor)
+72 -31
View File
@@ -3,13 +3,14 @@
ISR Web — Browse and download recorded audio files. ISR Web — Browse and download recorded audio files.
Shows a chronological table of all recordings, allows inline playback, Shows a chronological table of all recordings, allows inline playback,
download, and analyses WAV/FLAC files for loud sections using RMS. download, and analyses WAV/FLAC files for sections that stand out above the
background noise.
Usage: Usage:
python web.py # serves recordings/ on port 8080 python web.py # serves recordings/ on port 8080
python web.py --dir /path/to/audio # custom recordings directory python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05) python web.py --margin 15 # dB above noise floor (default 12)
""" """
import argparse import argparse
@@ -46,9 +47,14 @@ except ImportError:
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'} AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
LOUD_THRESHOLD = 0.05 # RMS 01 scale; sections above this are "interesting" MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
NOISE_PERCENTILE = 20 # percentile of windowed dB levels taken as the floor
MIN_RMS = 0.002 # ≈ 54 dBFS; the floor never drops below this, so
# digital silence does not make every tiny sound loud
MIME_TYPES = { MIME_TYPES = {
'.wav': 'audio/wav', '.wav': 'audio/wav',
'.mp3': 'audio/mpeg', '.mp3': 'audio/mpeg',
@@ -254,33 +260,64 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
yield round(rms, 5) yield round(rms, 5)
def _noise_floor_db(db_values: list, window_dur: float) -> list:
"""Per-window background noise floor in dBFS.
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
blocks so an event spanning a whole block cannot raise its own floor.
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
is relative to "how loud it normally is right now"."""
n = len(db_values)
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
floors = []
for i in range(0, n, block):
chunk = sorted(db_values[i:i + block])
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
def _loud_sections(rms_values: list, window_dur: float, duration: float, def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list: margin_db: float, min_gap: float = MIN_GAP_SECONDS) -> list:
"""Sections whose level rises at least margin_db above the local noise
floor. Each section carries a 'score': its peak dB above the floor, used
by the UI to rank sections by how much they stand out."""
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
floor = _noise_floor_db(db, window_dur)
min_db = 20 * math.log10(MIN_RMS)
sections = [] sections = []
start_t = None start_t = None
last_loud_t = None last_loud_t = None
peak = 0.0
for i, rms in enumerate(rms_values): for i, d in enumerate(db):
t = i * window_dur t = i * window_dur
if rms >= threshold: floor_eff = max(floor[i], min_db)
if d >= floor_eff + margin_db:
if start_t is None: if start_t is None:
start_t = t start_t = t
peak = 0.0
last_loud_t = t last_loud_t = t
peak = max(peak, d - floor_eff)
else: else:
if start_t is not None and (t - last_loud_t) > min_gap: if start_t is not None and (t - last_loud_t) > min_gap:
sections.append({'start': round(start_t, 1), sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1)}) 'end': round(last_loud_t + window_dur, 1),
'score': round(peak, 1)})
start_t = None start_t = None
last_loud_t = None last_loud_t = None
if start_t is not None: if start_t is not None:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)}) sections.append({'start': round(start_t, 1), 'end': round(duration, 1),
'score': round(peak, 1)})
return sections return sections
def _package_result(rms_values: list, framerate: int, n_frames: int, def _package_result(rms_values: list, framerate: int, n_frames: int,
window_samples: int, threshold: float, window_samples: int, margin_db: float,
min_gap: float = MIN_GAP_SECONDS) -> dict: min_gap: float = MIN_GAP_SECONDS) -> dict:
window_dur = window_samples / framerate window_dur = window_samples / framerate
duration = n_frames / framerate duration = n_frames / framerate
@@ -295,14 +332,14 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
# only renders rms_display (~800 points), and the full list is ~45x larger. # only renders rms_display (~800 points), and the full list is ~45x larger.
return { return {
'rms_display': rms_display, 'rms_display': rms_display,
'sections': _loud_sections(rms_values, window_dur, duration, threshold, min_gap), 'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap),
'duration': round(duration, 2), 'duration': round(duration, 2),
'window': round(window_dur, 4), 'window': round(window_dur, 4),
} }
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES, def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD, margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS) -> dict: min_gap: float = MIN_GAP_SECONDS) -> dict:
try: try:
with wave.open(str(path), 'rb') as wf: with wave.open(str(path), 'rb') as wf:
@@ -315,11 +352,11 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap) return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES, def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD, margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS) -> dict: min_gap: float = MIN_GAP_SECONDS) -> dict:
"""Analyse a FLAC file for loudness. Requires numpy and soundfile.""" """Analyse a FLAC file for loudness. Requires numpy and soundfile."""
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE: if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
@@ -342,7 +379,7 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap) return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -355,18 +392,19 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
def _cached_analysis_params(cache_path: Path): def _cached_analysis_params(cache_path: Path):
"""Read just threshold/min_gap from a cache file without parsing the whole """Read just margin/min_gap from a cache file without parsing the whole
JSON (the embedded result can be hundreds of KB). Relies on the writer in JSON (the embedded result can be hundreds of KB). Relies on the writer in
_api_analyze putting these two keys first.""" _api_analyze putting these two keys first. Caches written by the old
fixed-threshold detector have no margin key and simply never match."""
try: try:
with open(cache_path, 'r', encoding='utf-8') as fh: with open(cache_path, 'r', encoding='utf-8') as fh:
head = fh.read(256) head = fh.read(256)
except OSError: except OSError:
return None return None
m = re.search(r'"threshold":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head) m = re.search(r'"margin":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head)
if not m: if not m:
return None return None
return {'threshold': float(m.group(1)), 'min_gap': float(m.group(2))} return {'margin': float(m.group(1)), 'min_gap': float(m.group(2))}
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path): def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
@@ -465,7 +503,7 @@ class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings' recordings_dir: str = 'recordings'
analyses_dir: str = 'recordings/analyses' analyses_dir: str = 'recordings/analyses'
threshold: float = LOUD_THRESHOLD margin_db: float = MARGIN_DB
min_gap: float = MIN_GAP_SECONDS min_gap: float = MIN_GAP_SECONDS
def do_DELETE(self): def do_DELETE(self):
@@ -529,10 +567,10 @@ class _Handler(BaseHTTPRequestHandler):
return return
try: try:
threshold = float(qs.get('threshold', [self.threshold])[0]) margin = float(qs.get('margin', [self.margin_db])[0])
threshold = max(0.0, min(1.0, threshold)) margin = max(1.0, min(60.0, margin))
except (ValueError, TypeError): except (ValueError, TypeError):
threshold = self.threshold margin = self.margin_db
try: try:
min_gap = float(qs.get('min_gap', [self.min_gap])[0]) min_gap = float(qs.get('min_gap', [self.min_gap])[0])
@@ -549,7 +587,7 @@ class _Handler(BaseHTTPRequestHandler):
cache_path = _analysis_cache_path(analyses_base, recordings_base, path) cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
try: try:
cached = json.loads(cache_path.read_text('utf-8')) cached = json.loads(cache_path.read_text('utf-8'))
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap: if cached.get('margin') == margin and cached.get('min_gap') == min_gap:
payload = dict(cached['result']) payload = dict(cached['result'])
payload.pop('rms', None) # caches written before the full-RMS field was dropped payload.pop('rms', None) # caches written before the full-RMS field was dropped
payload['cached'] = True payload['cached'] = True
@@ -560,12 +598,12 @@ class _Handler(BaseHTTPRequestHandler):
ext = path.suffix.lower() ext = path.suffix.lower()
if ext == '.wav': if ext == '.wav':
result = analyze_wav(path, threshold=threshold, min_gap=min_gap) result = analyze_wav(path, margin_db=margin, min_gap=min_gap)
elif ext == '.flac': elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE): if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile') self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
return return
result = analyze_flac(path, threshold=threshold, min_gap=min_gap) result = analyze_flac(path, margin_db=margin, min_gap=min_gap)
else: else:
self._json_err(400, f'Loudness analysis is not available for {ext} files') self._json_err(400, f'Loudness analysis is not available for {ext} files')
return return
@@ -573,7 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
try: try:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = cache_path.with_suffix('.tmp') tmp = cache_path.with_suffix('.tmp')
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8') # margin and min_gap MUST stay first: _cached_analysis_params reads
# only the first 256 bytes of this file
tmp.write_text(json.dumps({'margin': margin, 'min_gap': min_gap, 'result': result}), 'utf-8')
os.replace(tmp, cache_path) os.replace(tmp, cache_path)
except Exception as e: except Exception as e:
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True) print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
@@ -690,7 +730,7 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, data.encode(), 'application/json') self._send(200, data.encode(), 'application/json')
def _api_config(self): def _api_config(self):
data = json.dumps({'threshold': self.threshold, 'min_gap': self.min_gap}) data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap})
self._send(200, data.encode(), 'application/json') self._send(200, data.encode(), 'application/json')
def _api_delete(self, filename: str): def _api_delete(self, filename: str):
@@ -873,8 +913,9 @@ def main():
help='HTTP port (default: 8080)') help='HTTP port (default: 8080)')
parser.add_argument('--host', default='0.0.0.0', parser.add_argument('--host', default='0.0.0.0',
help='Bind address (default: 0.0.0.0)') help='Bind address (default: 0.0.0.0)')
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD, parser.add_argument('--margin', type=float, default=MARGIN_DB,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})') help=f'dB above the background noise floor for a section '
f'to count as loud (default: {MARGIN_DB})')
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS, parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})') help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
parser.add_argument('--analyses-dir', default=None, parser.add_argument('--analyses-dir', default=None,
@@ -893,7 +934,7 @@ def main():
class Handler(_Handler): class Handler(_Handler):
recordings_dir = str(rec_dir) recordings_dir = str(rec_dir)
analyses_dir = str(_analyses_dir) analyses_dir = str(_analyses_dir)
threshold = args.threshold margin_db = args.margin
min_gap = args.min_gap min_gap = args.min_gap
server = _Server((args.host, args.port), Handler) server = _Server((args.host, args.port), Handler)
@@ -901,7 +942,7 @@ def main():
print(f"ISR Web running on http://{args.host}:{args.port}/") print(f"ISR Web running on http://{args.host}:{args.port}/")
print(f"Recordings dir: {rec_dir}") print(f"Recordings dir: {rec_dir}")
print(f"Analyses dir: {analyses_dir}") print(f"Analyses dir: {analyses_dir}")
print(f"Loud threshold: {args.threshold}") print(f"Loudness margin: {args.margin} dB above noise floor")
if not NUMPY_AVAILABLE: if not NUMPY_AVAILABLE:
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable") print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
elif not SOUNDFILE_AVAILABLE: elif not SOUNDFILE_AVAILABLE:
+32 -24
View File
@@ -133,10 +133,10 @@ svg.day-timeline{display:block;width:100%;height:22px}
<button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button> <button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
</header> </header>
<div class="controls-bar"> <div class="controls-bar">
<label for="threshold-input">Analysis threshold:</label> <label for="margin-input">Loudness margin:</label>
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05" <input type="number" id="margin-input" min="1" max="60" step="1" value="12"
aria-describedby="threshold-hint"> aria-describedby="margin-hint">
<span id="threshold-hint" class="controls-hint">RMS amplitude 01 (linear; 0.05 ≈ 26 dBFS) · sections above this are marked loud</span> <span id="margin-hint" class="controls-hint">dB above background noise — sections that rise this far above the rolling noise floor are marked loud</span>
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label> <label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" <input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
aria-describedby="preroll-hint"> aria-describedby="preroll-hint">
@@ -321,15 +321,15 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
} }
} }
// filename|threshold|gap -> analysis result, so re-renders (filtering, // filename|margin|gap -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has // refresh) never refetch what this session already has
const analysisCache = new Map(); const analysisCache = new Map();
async function fetchAnalysis(filename, threshold, minGap, force = false) { async function fetchAnalysis(filename, margin, minGap, force = false) {
const key = `${filename}|${threshold}|${minGap}`; const key = `${filename}|${margin}|${minGap}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key); if (!force && analysisCache.has(key)) return analysisCache.get(key);
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename) const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&threshold='+encodeURIComponent(threshold) +'&margin='+encodeURIComponent(margin)
+'&min_gap='+encodeURIComponent(minGap)); +'&min_gap='+encodeURIComponent(minGap));
const d = await r.json(); const d = await r.json();
if (!d.error) analysisCache.set(key, d); if (!d.error) analysisCache.set(key, d);
@@ -340,7 +340,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
btn.disabled = true; btn.disabled = true;
btn.textContent = '…'; btn.textContent = '…';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const threshold = document.getElementById('threshold-input').value || '0.05'; const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2'; const minGap = document.getElementById('min-gap-input').value || '2';
const restoreBtn = () => { const restoreBtn = () => {
btn.textContent = 'Analyse'; btn.disabled = false; btn.textContent = 'Analyse'; btn.disabled = false;
@@ -348,7 +348,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
if (!cell.contains(btn)) cell.appendChild(btn); if (!cell.contains(btn)) cell.appendChild(btn);
}; };
try { try {
const d = await fetchAnalysis(filename, threshold, minGap, force); const d = await fetchAnalysis(filename, margin, minGap, force);
if (d.error) { if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`; cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
restoreBtn(); return; restoreBtn(); return;
@@ -357,7 +357,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename)); box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
const meta = document.createElement('div'); meta.className='analysis-meta'; const meta = document.createElement('div'); meta.className='analysis-meta';
meta.textContent = `threshold: ${threshold} · gap: ${minGap}s${d.cached ? ' · cached' : ''}`; meta.textContent = `margin: ${margin} dB · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
box.appendChild(meta); box.appendChild(meta);
const chips = document.createElement('div'); const chips = document.createElement('div');
@@ -370,7 +370,8 @@ async function analyse(idx, filename, cell, btn, force = false) {
const c = document.createElement('button'); const c = document.createElement('button');
c.className='chip'; c.className='chip';
c.title = 'Jump to this section (or use J/K keys)'; c.title = 'Jump to this section (or use J/K keys)';
c.textContent = `${fmtDur(s.start)} ${fmtDur(s.end)}`; c.textContent = `${fmtDur(s.start)} ${fmtDur(s.end)}`
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si)); c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
chips.appendChild(c); chips.appendChild(c);
}); });
@@ -494,7 +495,7 @@ async function updateStorage() {
// Auto-loading on a mismatch would silently recompute every file. // Auto-loading on a mismatch would silently recompute every file.
function cachedParamsMatch(ca) { function cachedParamsMatch(ca) {
return ca != null return ca != null
&& Number(ca.threshold) === parseFloat(document.getElementById('threshold-input').value) && Number(ca.margin) === parseFloat(document.getElementById('margin-input').value)
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value); && Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
} }
@@ -769,7 +770,7 @@ async function dayHighlights(dayId, analyzableFiles) {
progWrap.appendChild(progBar); progWrap.appendChild(progFile); progWrap.appendChild(progBar); progWrap.appendChild(progFile);
contentEl.innerHTML = ''; contentEl.appendChild(progWrap); contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
const threshold = document.getElementById('threshold-input').value || '0.05'; const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2'; const minGap = document.getElementById('min-gap-input').value || '2';
const results = []; const results = [];
@@ -779,7 +780,7 @@ async function dayHighlights(dayId, analyzableFiles) {
progFile.textContent = `${i + 1} / ${n} — ${f.name}`; progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`; progFill.style.width = `${(i / n) * 100}%`;
try { try {
const d = await fetchAnalysis(f.name, threshold, minGap); const d = await fetchAnalysis(f.name, margin, minGap);
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; } if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
} catch(e) {} } catch(e) {}
} }
@@ -868,6 +869,7 @@ async function dayHighlights(dayId, analyzableFiles) {
filename: f.name, filename: f.name,
start: s.start, start: s.start,
end: s.end, end: s.end,
score: s.score,
absStart: fileStart + s.start, absStart: fileStart + s.start,
}); });
}); });
@@ -888,18 +890,25 @@ async function dayHighlights(dayId, analyzableFiles) {
if (dayActiveSections.length) { if (dayActiveSections.length) {
const MAX_DAY_CHIPS = 50; const MAX_DAY_CHIPS = 50;
if (dayActiveSections.length > MAX_DAY_CHIPS) { // When there are too many sections to show them all, show the ones most
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
let chipList = dayActiveSections.map((sec, si) => ({sec, si}));
const truncated = chipList.length > MAX_DAY_CHIPS;
if (truncated) {
chipList = chipList
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
.slice(0, MAX_DAY_CHIPS);
const note = document.createElement('p'); const note = document.createElement('p');
note.className = 'quiet'; note.className = 'quiet';
note.style.marginTop = '6px'; note.style.marginTop = '6px';
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`; note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order`;
box.appendChild(note); box.appendChild(note);
} else { }
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className = 'chips'; chips.className = 'chips';
chips.setAttribute('role', 'group'); chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files'); 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'); const c = document.createElement('button');
c.className = 'chip'; c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start); c.title = sec.filename + ' @ ' + fmtDur(sec.start);
@@ -907,13 +916,12 @@ async function dayHighlights(dayId, analyzableFiles) {
const hms = d.getHours().toString().padStart(2,'0') + ':' const hms = d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0') + ':'
+ d.getSeconds().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)); c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c); chips.appendChild(c);
}); });
box.appendChild(chips); box.appendChild(chips);
} }
}
const summary = document.createElement('div'); const summary = document.createElement('div');
summary.className = 'quiet'; summary.className = 'quiet';
@@ -1009,10 +1017,10 @@ document.getElementById('filter-clear').addEventListener('click', () => {
applyFilters(); applyFilters();
}); });
// Seed threshold and min_gap from server config, then start // Seed margin and min_gap from server config, then start
fetch('/api/config').then(r => r.json()).then(cfg => { fetch('/api/config').then(r => r.json()).then(cfg => {
if (cfg.threshold != null) if (cfg.margin != null)
document.getElementById('threshold-input').value = cfg.threshold; document.getElementById('margin-input').value = cfg.margin;
if (cfg.min_gap != null) if (cfg.min_gap != null)
document.getElementById('min-gap-input').value = cfg.min_gap; document.getElementById('min-gap-input').value = cfg.min_gap;
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000))); }).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));