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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user