diff --git a/CLAUDE.md b/CLAUDE.md index e9516ec..24d039a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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,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, --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 ``` @@ -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. - **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 `/.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 `/.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. - **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. diff --git a/README.md b/README.md index d4e15c3..e9914da 100644 --- a/README.md +++ b/README.md @@ -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 --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: /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: - **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/.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/.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. - **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). diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..761f933 --- /dev/null +++ b/tests/test_web.py @@ -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) diff --git a/web.py b/web.py index d0b9d98..4fb74c6 100644 --- a/web.py +++ b/web.py @@ -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,14 @@ 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 + MIME_TYPES = { '.wav': 'audio/wav', '.mp3': 'audio/mpeg', @@ -254,33 +260,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 +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. 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 +352,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 +379,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 +392,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 +503,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): @@ -529,10 +567,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 +587,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 +598,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 +611,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 +730,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): @@ -873,8 +913,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 +934,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 +942,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: diff --git a/webui.html b/webui.html index b127fb0..b0eadf9 100644 --- a/webui.html +++ b/webui.html @@ -133,10 +133,10 @@ svg.day-timeline{display:block;width:100%;height:22px}
- - - RMS amplitude 0–1 (linear; 0.05 ≈ −26 dBFS) · sections above this are marked loud + + + dB above background noise — sections that rise this far above the rolling noise floor are marked loud @@ -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 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,15 +340,15 @@ async function analyse(idx, filename, cell, btn, force = false) { btn.disabled = true; btn.textContent = '…'; cell.innerHTML = '
Analysing…
'; - const threshold = document.getElementById('threshold-input').value || '0.05'; - const minGap = document.getElementById('min-gap-input').value || '2'; + 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; btn.onclick = () => analyse(idx, filename, cell, btn); 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 = ``; 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)); 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'); @@ -370,7 +370,8 @@ async function analyse(idx, filename, cell, btn, force = false) { 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.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)); chips.appendChild(c); }); @@ -494,8 +495,8 @@ 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.min_gap) === parseFloat(document.getElementById('min-gap-input').value); + && Number(ca.margin) === parseFloat(document.getElementById('margin-input').value) + && Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value); } // Run the deferred analyses of a freshly expanded day @@ -769,8 +770,8 @@ 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 minGap = document.getElementById('min-gap-input').value || '2'; + const margin = document.getElementById('margin-input').value || '12'; + const minGap = document.getElementById('min-gap-input').value || '2'; const results = []; let nCached = 0, nLive = 0; @@ -779,7 +780,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,6 +869,7 @@ async function dayHighlights(dayId, analyzableFiles) { filename: f.name, start: s.start, end: s.end, + score: s.score, absStart: fileStart + s.start, }); }); @@ -888,31 +890,37 @@ 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) => { - const c = document.createElement('button'); - c.className = 'chip'; - c.title = sec.filename + ' @ ' + fmtDur(sec.start); - const d = new Date(sec.absStart * 1000); - const hms = d.getHours().toString().padStart(2,'0') + ':' - + d.getMinutes().toString().padStart(2,'0') + ':' - + d.getSeconds().toString().padStart(2,'0'); - c.textContent = hms; - c.addEventListener('click', () => jumpToDaySection(si)); - chips.appendChild(c); - }); - box.appendChild(chips); } + const chips = document.createElement('div'); + chips.className = 'chips'; + chips.setAttribute('role', 'group'); + chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files'); + chipList.forEach(({sec, si}) => { + const c = document.createElement('button'); + c.className = 'chip'; + c.title = sec.filename + ' @ ' + fmtDur(sec.start); + const d = new Date(sec.absStart * 1000); + const hms = d.getHours().toString().padStart(2,'0') + ':' + + d.getMinutes().toString().padStart(2,'0') + ':' + + d.getSeconds().toString().padStart(2,'0'); + c.textContent = hms + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : ''); + c.addEventListener('click', () => jumpToDaySection(si)); + chips.appendChild(c); + }); + box.appendChild(chips); } const summary = document.createElement('div'); @@ -1009,10 +1017,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)));