feat: minimum section duration filter (--min-duration, default 0.5 s)

A single 100 ms RMS window above the noise floor used to become its own
section, so isolated pops (clicks, single raindrops) flooded a day with
thousands of sub-second clips like "21:18 to 21:18". Sections shorter
than min_duration (measured after min_gap merging, so a cluster of blips
spanning longer still flags) are now discarded.

Wired through all coupled places: CLI flag, /api/config, controls-bar
input, /api/analyze query param, and the analysis-cache head keys (old
two-key caches no longer match and are recomputed on next analyse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 09:00:37 +02:00
parent e4d82483b5
commit f3716d3ff1
5 changed files with 114 additions and 42 deletions
+53 -25
View File
@@ -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)