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
+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)