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
+18 -8
View File
@@ -155,6 +155,10 @@ body.clip-open{padding-bottom:70px}
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
aria-describedby="min-gap-hint">
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
<label for="min-duration-input" style="margin-left:16px">Min duration:</label>
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" value="0.5"
aria-describedby="min-duration-hint">
<span id="min-duration-hint" class="controls-hint">seconds — ignore loud sections shorter than this</span>
</div>
<div class="filter-bar" role="search" aria-label="Filter recordings">
<label for="filter-name">Search:</label>
@@ -403,16 +407,17 @@ document.getElementById('clip-context').addEventListener('click', () => {
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
});
// filename|margin|gap -> analysis result, so re-renders (filtering,
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has
const analysisCache = new Map();
async function fetchAnalysis(filename, margin, minGap, force = false) {
const key = `${filename}|${margin}|${minGap}`;
async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
const key = `${filename}|${margin}|${minGap}|${minDur}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key);
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&margin='+encodeURIComponent(margin)
+'&min_gap='+encodeURIComponent(minGap));
+'&min_gap='+encodeURIComponent(minGap)
+'&min_duration='+encodeURIComponent(minDur));
const d = await r.json();
if (!d.error) analysisCache.set(key, d);
return d;
@@ -424,13 +429,14 @@ async function analyse(idx, filename, cell, btn, force = false) {
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2';
const minDur = document.getElementById('min-duration-input').value || '0.5';
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, margin, minGap, force);
const d = await fetchAnalysis(filename, margin, minGap, minDur, force);
if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
restoreBtn(); return;
@@ -439,7 +445,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 = `margin: ${margin} dB · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
meta.textContent = `margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}s${d.cached ? ' · cached' : ''}`;
box.appendChild(meta);
const chips = document.createElement('div');
@@ -576,7 +582,8 @@ async function updateStorage() {
function cachedParamsMatch(ca) {
return ca != null
&& 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)
&& Number(ca.min_duration) === parseFloat(document.getElementById('min-duration-input').value);
}
// Run the deferred analyses of a freshly expanded day
@@ -852,6 +859,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2';
const minDur = document.getElementById('min-duration-input').value || '0.5';
const results = [];
let nCached = 0, nLive = 0;
@@ -860,7 +868,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, margin, minGap);
const d = await fetchAnalysis(f.name, margin, minGap, minDur);
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
} catch(e) {}
}
@@ -1099,6 +1107,8 @@ fetch('/api/config').then(r => r.json()).then(cfg => {
document.getElementById('margin-input').value = cfg.margin;
if (cfg.min_gap != null)
document.getElementById('min-gap-input').value = cfg.min_gap;
if (cfg.min_duration != null)
document.getElementById('min-duration-input').value = cfg.min_duration;
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
</script>
</body>