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:
+49
-41
@@ -133,10 +133,10 @@ svg.day-timeline{display:block;width:100%;height:22px}
|
||||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||||
</header>
|
||||
<div class="controls-bar">
|
||||
<label for="threshold-input">Analysis threshold:</label>
|
||||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||||
aria-describedby="threshold-hint">
|
||||
<span id="threshold-hint" class="controls-hint">RMS amplitude 0–1 (linear; 0.05 ≈ −26 dBFS) · sections above this are marked loud</span>
|
||||
<label for="margin-input">Loudness margin:</label>
|
||||
<input type="number" id="margin-input" min="1" max="60" step="1" value="12"
|
||||
aria-describedby="margin-hint">
|
||||
<span id="margin-hint" class="controls-hint">dB above background noise — sections that rise this far above the rolling noise floor are marked loud</span>
|
||||
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
||||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||||
aria-describedby="preroll-hint">
|
||||
@@ -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 = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||||
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 = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||||
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)));
|
||||
|
||||
Reference in New Issue
Block a user