feat: day-grouped table, day highlights timeline, and grace period for loud sections

- Recordings table now groups files by day with collapsible headings
  showing file count, total duration, and size; most recent day expands
  by default, older days start collapsed
- Day highlights button runs loudness analysis across all WAV/FLAC files
  in a day and renders a combined SVG activity timeline (blue = file
  extents, orange = loud sections) with time labels
- Grace period control (default 2 s, max 300 s) merges loud sections
  separated by less than this gap; exposed in the controls bar, via
  --min-gap CLI flag, and as a per-request min_gap param on /api/analyze
- Delete re-renders via applyFilters so day headings stay consistent
This commit is contained in:
2026-05-07 04:06:30 +02:00
parent 47ce682821
commit d84056929a
2 changed files with 379 additions and 137 deletions
+6 -2
View File
@@ -151,16 +151,20 @@ python web.py # serves ./recordings on port 8080
python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold 01 (default 0.05)
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
```
Shows a table of all recordings sorted newest-first. Features:
Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
- **Day highlights** — click **★ Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times.
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent.
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it.
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing.
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and removes the row without a full page reload.
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` (header is unfinalized until recording stops).
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
+373 -135
View File
@@ -114,7 +114,7 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float) -> list:
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list:
sections = []
start_t = None
last_loud_t = None
@@ -126,7 +126,7 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
start_t = t
last_loud_t = t
else:
if start_t is not None and (t - last_loud_t) > MIN_GAP_SECONDS:
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)})
start_t = None
@@ -139,7 +139,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, threshold: float) -> dict:
window_samples: int, threshold: float,
min_gap: float = MIN_GAP_SECONDS) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
@@ -152,14 +153,15 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
return {
'rms': rms_values,
'rms_display': rms_display,
'sections': _loud_sections(rms_values, window_dur, duration, threshold),
'sections': _loud_sections(rms_values, window_dur, duration, threshold, min_gap),
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD) -> dict:
threshold: float = LOUD_THRESHOLD,
min_gap: float = MIN_GAP_SECONDS) -> dict:
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
@@ -171,11 +173,12 @@ 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, threshold)
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD) -> dict:
threshold: float = LOUD_THRESHOLD,
min_gap: float = MIN_GAP_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'}
@@ -197,7 +200,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, threshold)
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap)
# ---------------------------------------------------------------------------
@@ -254,6 +257,7 @@ def list_files(recordings_dir: str):
class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings'
threshold: float = LOUD_THRESHOLD
min_gap: float = MIN_GAP_SECONDS
def do_DELETE(self):
parsed = urlparse(self.path)
@@ -312,6 +316,12 @@ class _Handler(BaseHTTPRequestHandler):
except (ValueError, TypeError):
threshold = self.threshold
try:
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
min_gap = max(0.0, min(300.0, min_gap))
except (ValueError, TypeError):
min_gap = self.min_gap
status_path = Path(self.recordings_dir) / 'status.json'
try:
with open(status_path) as fh:
@@ -323,12 +333,12 @@ class _Handler(BaseHTTPRequestHandler):
ext = path.suffix.lower()
if ext == '.wav':
result = analyze_wav(path, threshold=threshold)
result = analyze_wav(path, threshold=threshold, min_gap=min_gap)
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, threshold=threshold)
result = analyze_flac(path, threshold=threshold, min_gap=min_gap)
else:
self._json_err(400, f'Loudness analysis is not available for {ext} files')
return
@@ -432,7 +442,7 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, data.encode(), 'application/json')
def _api_config(self):
data = json.dumps({'threshold': self.threshold})
data = json.dumps({'threshold': self.threshold, 'min_gap': self.min_gap})
self._send(200, data.encode(), 'application/json')
def _api_delete(self, filename: str):
@@ -667,6 +677,21 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
/* day groups */
tr.day-head td{background:var(--surf);padding:7px 10px;border-bottom:2px solid var(--brd);border-top:2px solid var(--brd)}
.day-toggle{background:none;border:none;color:var(--txt);font-size:13px;font-weight:600;
cursor:pointer;padding:2px 0;display:inline-flex;align-items:center;gap:8px}
.day-toggle:hover{color:var(--accent)}
.day-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
.day-meta{color:var(--muted);font-size:12px;font-weight:400}
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px;
margin-left:14px;vertical-align:middle}
button.day-hl:hover:not(:disabled){background:#0a3d1f}
button.day-hl:disabled{opacity:.5;cursor:default}
tr.day-hl-row td{background:var(--bg);padding:8px 12px 12px}
svg.day-timeline{display:block;width:100%;height:22px}
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
</style>
</head>
<body>
@@ -687,6 +712,10 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
aria-describedby="preroll-hint">
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
<label for="min-gap-input" style="margin-left:16px">Grace period:</label>
<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>
</div>
<div class="filter-bar" role="search" aria-label="Filter recordings">
<label for="filter-name">Search:</label>
@@ -759,6 +788,18 @@ const sectionMap = new Map();
let activePlayerIdx = null;
// full file list from server, annotated with stable _idx
let allFiles = [];
// dayId -> boolean, persists expanded state across re-renders
const dayExpanded = new Map();
function groupByDay(files) {
const map = new Map();
files.forEach(f => {
const day = f.date.slice(0, 10);
if (!map.has(day)) map.set(day, []);
map.get(day).push(f);
});
return map;
}
function togglePlayer(idx, filename) {
const prow = document.getElementById('prow-'+idx);
@@ -854,9 +895,11 @@ async function analyse(idx, filename, cell, btn) {
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';
try {
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&threshold='+encodeURIComponent(threshold));
+'&threshold='+encodeURIComponent(threshold)
+'&min_gap='+encodeURIComponent(minGap));
const d = await r.json();
if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
@@ -941,30 +984,22 @@ document.addEventListener('keydown', e => {
async function deleteFile(idx, filename) {
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
const btn = document.getElementById('delbtn-'+idx);
btn.disabled = true;
btn.textContent = '';
if (btn) { btn.disabled = true; btn.textContent = ''; }
try {
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
if (r.ok) {
document.getElementById('row-'+idx)?.remove();
document.getElementById('prow-'+idx)?.remove();
recMap.delete(idx);
allFiles = allFiles.filter(f => f._idx !== idx);
const visible = document.querySelectorAll('tr.data-row').length;
const total = allFiles.length;
document.getElementById('subtitle').textContent = total === visible
? `${total} recording${total!==1?'s':''} found`
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
if (!visible) document.getElementById('empty').style.display = '';
recMap.delete(idx);
applyFilters();
updateStorage();
} else {
const d = await r.json().catch(()=>({}));
alert('Delete failed: '+(d.error||r.statusText));
btn.disabled = false; btn.textContent = '✕ Delete';
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
}
} catch(e) {
alert('Delete failed: '+e.message);
btn.disabled = false; btn.textContent = '✕ Delete';
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
}
}
@@ -979,6 +1014,55 @@ async function updateStorage() {
} catch(e) {}
}
function _attachFileRowHandlers(f, isRec, expanded, dayId) {
const i = f._idx;
const ext = f.ext;
const canAnalyse = ext === 'wav' || ext === 'flac';
if (canAnalyse) {
const cell = document.getElementById('wave-'+i);
const abtn = document.createElement('button');
abtn.textContent = 'Analyse';
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
if (isRec) {
abtn.disabled = true;
abtn.title = 'Recording in progress — analyse after recording stops';
} else {
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
}
cell.appendChild(abtn);
}
document.getElementById('pbtn-'+i)
.addEventListener('click', () => togglePlayer(i, f.name));
if (!isRec) {
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
const pbtn = document.getElementById('pbtn-'+i);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
document.getElementById('cut-start-'+i)?.focus();
});
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
const startStr = document.getElementById('cut-start-'+i).value;
const endStr = document.getElementById('cut-end-'+i).value;
const start = parseTime(startStr);
const end = parseTime(endStr);
if (start === null || end === null) {
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
return;
}
if (start >= end) { alert('Start must be before end'); return; }
window.location.href =
'/api/cut?file=' + encodeURIComponent(f.name) +
'&start=' + start + '&end=' + end;
});
document.getElementById('delbtn-'+i)
.addEventListener('click', () => deleteFile(i, f.name));
}
recMap.set(i, f.name);
}
function renderFiles(files) {
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
@@ -992,129 +1076,278 @@ function renderFiles(files) {
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
document.getElementById('empty').style.display = visible ? 'none' : '';
files.forEach(f => {
const i = f._idx;
const ext = f.ext;
const canAnalyse = ext === 'wav' || ext === 'flac';
const isRec = !!f.recording;
const days = groupByDay(files);
let isFirst = true;
// ---- main data row ----
const tr = document.createElement('tr');
tr.className = 'data-row';
tr.id = 'row-'+i;
days.forEach((dayFiles, day) => {
const dayId = 'day-' + day.replace(/-/g, '');
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, isFirst);
const expanded = dayExpanded.get(dayId);
isFirst = false;
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
aria-label="Currently recording" aria-hidden="${isRec?'false':'true'}">
<span aria-hidden="true">●</span> REC</span>`;
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
const sizeStr = ' · ' + fmtSize(totalSize);
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
tr.innerHTML = `
<td>
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
</td>
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
<td id="wave-${i}">${canAnalyse ? '' :
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
<td>
<div class="actions">
<button id="pbtn-${i}"
aria-expanded="false"
aria-controls="prow-${i}"
aria-label="Play ${esc(f.name)}">▶ Play</button>
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
aria-label="Download ${esc(f.name)}">↓ Download</a>
<button id="cutbtn-${i}" class="cut"
aria-label="Cut ${esc(f.name)}"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
<button id="delbtn-${i}" class="del"
aria-label="Delete ${esc(f.name)}"
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
// Day heading row
const headRow = document.createElement('tr');
headRow.className = 'day-head';
headRow.id = 'dayhead-' + dayId;
headRow.innerHTML = `<td colspan="6">
<button class="day-toggle" id="daytgl-${dayId}"
aria-expanded="${expanded}"
aria-controls="${dayId}-body"
aria-label="${expanded ? 'Collapse' : 'Expand'} ${esc(day)}">
<span class="day-arrow" aria-hidden="true">${expanded ? '' : ''}</span>
<strong>${esc(day)}</strong>
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
</button>
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}
</td>`;
tbody.appendChild(headRow);
// Highlights row (hidden until button clicked)
const hlRow = document.createElement('tr');
hlRow.className = 'day-hl-row';
hlRow.id = 'dayhl-' + dayId;
hlRow.hidden = true;
hlRow.innerHTML = `<td colspan="6"><div id="dayhlc-${dayId}"></div></td>`;
tbody.appendChild(hlRow);
// File rows
dayFiles.forEach(f => {
const i = f._idx;
const ext = f.ext;
const canAnalyse = ext === 'wav' || ext === 'flac';
const isRec = !!f.recording;
const tr = document.createElement('tr');
tr.className = 'data-row';
tr.id = 'row-'+i;
tr.setAttribute('data-day', dayId);
if (!expanded) tr.hidden = true;
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
aria-label="Currently recording" aria-hidden="${isRec?'false':'true'}">
<span aria-hidden="true">●</span> REC</span>`;
tr.innerHTML = `
<td>
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
</td>
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
<td id="wave-${i}">${canAnalyse ? '' :
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
<td>
<div class="actions">
<button id="pbtn-${i}"
aria-expanded="false"
aria-controls="prow-${i}"
aria-label="Play ${esc(f.name)}">▶ Play</button>
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
aria-label="Download ${esc(f.name)}">↓ Download</a>
<button id="cutbtn-${i}" class="cut"
aria-label="Cut ${esc(f.name)}"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
<button id="delbtn-${i}" class="del"
aria-label="Delete ${esc(f.name)}"
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
</div>
</td>`;
tbody.appendChild(tr);
const prow = document.createElement('tr');
prow.className = 'player-row';
prow.id = 'prow-'+i;
prow.hidden = true;
prow.setAttribute('data-day', dayId);
const durLabel = f.duration != null
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
: '';
prow.innerHTML = `<td colspan="6">
<audio id="aud-${i}" controls preload="none"
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
<div class="cut-panel">
<span class="cut-label">✂ Cut:</span>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<label class="cut-field">End
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<button id="cut-dl-${i}" class="cut"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
</div>
</td>`;
tbody.appendChild(tr);
tbody.appendChild(prow);
// ---- player row (hidden by default) ----
const prow = document.createElement('tr');
prow.className = 'player-row';
prow.id = 'prow-'+i;
prow.hidden = true;
const durLabel = f.duration != null
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
: '';
prow.innerHTML = `<td colspan="6">
<audio id="aud-${i}" controls preload="none"
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
<div class="cut-panel">
<span class="cut-label">✂ Cut:</span>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<label class="cut-field">End
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<button id="cut-dl-${i}" class="cut"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
</div>
</td>`;
tbody.appendChild(prow);
_attachFileRowHandlers(f, isRec, expanded, dayId);
});
// ---- attach analyse button ----
if (canAnalyse) {
const cell = document.getElementById('wave-'+i);
const abtn = document.createElement('button');
abtn.textContent = 'Analyse';
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
if (isRec) {
abtn.disabled = true;
abtn.title = 'Recording in progress — analyse after recording stops';
} else {
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
// Day toggle handler
document.getElementById('daytgl-' + dayId).addEventListener('click', () => {
const nowExp = !dayExpanded.get(dayId);
dayExpanded.set(dayId, nowExp);
const tgl = document.getElementById('daytgl-' + dayId);
tgl.setAttribute('aria-expanded', nowExp);
tgl.setAttribute('aria-label', (nowExp ? 'Collapse' : 'Expand') + ' ' + day);
tgl.querySelector('.day-arrow').textContent = nowExp ? '' : '';
document.querySelectorAll(`tr[data-day="${dayId}"].data-row`).forEach(r => {
r.hidden = !nowExp;
});
if (!nowExp) {
// Collapse: pause any open players, hide player rows and highlights
dayFiles.forEach(f => {
const prow = document.getElementById('prow-' + f._idx);
if (prow && !prow.hidden) {
document.getElementById('aud-' + f._idx)?.pause();
prow.hidden = true;
const pbtn = document.getElementById('pbtn-' + f._idx);
if (pbtn) {
pbtn.setAttribute('aria-expanded', 'false');
pbtn.textContent = '▶ Play';
pbtn.setAttribute('aria-label', 'Play ' + f.name);
}
}
});
document.getElementById('dayhl-' + dayId).hidden = true;
}
cell.appendChild(abtn);
}
});
// ---- attach play button handler ----
document.getElementById('pbtn-'+i)
.addEventListener('click', () => togglePlayer(i, f.name));
// ---- attach cut button handler (opens player row, focuses start field) ----
if (!isRec) {
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
const pbtn = document.getElementById('pbtn-'+i);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
document.getElementById('cut-start-'+i)?.focus();
});
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
const startStr = document.getElementById('cut-start-'+i).value;
const endStr = document.getElementById('cut-end-'+i).value;
const start = parseTime(startStr);
const end = parseTime(endStr);
if (start === null || end === null) {
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
return;
}
if (start >= end) {
alert('Start must be before end');
return;
}
window.location.href =
'/api/cut?file=' + encodeURIComponent(f.name) +
'&start=' + start + '&end=' + end;
// Highlights button handler
if (canHl) {
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
dayHighlights(dayId, hlFiles);
});
}
// ---- attach delete button handler ----
if (!isRec) {
document.getElementById('delbtn-'+i)
.addEventListener('click', () => deleteFile(i, f.name));
}
recMap.set(i, f.name);
});
}
async function dayHighlights(dayId, analyzableFiles) {
const hlRow = document.getElementById('dayhl-' + dayId);
const contentEl = document.getElementById('dayhlc-' + dayId);
const btn = document.getElementById('dayhln-' + dayId);
hlRow.hidden = false;
const n = analyzableFiles.length;
contentEl.innerHTML = `<div class="spin" aria-live="polite" aria-busy="true">Analysing ${n} file${n!==1?'s':''}…</div>`;
if (btn) btn.disabled = true;
const threshold = document.getElementById('threshold-input').value || '0.05';
const minGap = document.getElementById('min-gap-input').value || '2';
const results = [];
for (const f of analyzableFiles) {
try {
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
+ '&threshold=' + encodeURIComponent(threshold)
+ '&min_gap=' + encodeURIComponent(minGap));
const d = await r.json();
if (!d.error) results.push({ f, data: d });
} catch(e) {}
}
if (!results.length) {
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
if (btn) btn.disabled = false;
return;
}
// Map files onto the day timeline using mtime as file-end, duration for start
const positioned = results.map(({ f, data }) => {
const fileEnd = f.mtime;
const fileDur = data.duration || f.duration || 0;
const fileStart = fileEnd - fileDur;
return { f, data, fileStart, fileEnd, fileDur };
}).filter(r => r.fileDur > 0);
if (!positioned.length) {
contentEl.innerHTML = '<div class="quiet">Could not determine file time positions.</div>';
if (btn) btn.disabled = false;
return;
}
const minT = Math.min(...positioned.map(r => r.fileStart));
const maxT = Math.max(...positioned.map(r => r.fileEnd));
const spanT = maxT - minT || 1;
const W = 800, H = 22;
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('class', 'wave day-timeline');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('role', 'img');
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
svg.setAttribute('aria-label', `Day activity: ${results.length} file${results.length!==1?'s':''}, ${totalSecs} loud section${totalSecs!==1?'s':''}`);
// Background track
const bgR = document.createElementNS(ns, 'rect');
bgR.setAttribute('x', 0); bgR.setAttribute('y', 7);
bgR.setAttribute('width', W); bgR.setAttribute('height', 8);
bgR.setAttribute('fill', '#1e2535');
svg.appendChild(bgR);
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
const fx = ((fileStart - minT) / spanT) * W;
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
// File span (dim blue)
const fr = document.createElementNS(ns, 'rect');
fr.setAttribute('x', fx); fr.setAttribute('y', 8);
fr.setAttribute('width', fw); fr.setAttribute('height', 6);
fr.setAttribute('fill', '#1e3a5f');
fr.setAttribute('aria-hidden', 'true');
svg.appendChild(fr);
// Loud sections (orange)
(data.sections || []).forEach(s => {
const sx = fx + (s.start / fileDur) * fw;
const sw = Math.max(1, ((s.end - s.start) / fileDur) * fw);
const sr = document.createElementNS(ns, 'rect');
sr.setAttribute('x', sx); sr.setAttribute('y', 4);
sr.setAttribute('width', sw); sr.setAttribute('height', 14);
sr.setAttribute('fill', '#f97316');
sr.setAttribute('rx', '1');
sr.setAttribute('aria-hidden', 'true');
svg.appendChild(sr);
});
});
const fmtHM = ts => {
const d = new Date(ts * 1000);
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
};
const box = document.createElement('div');
box.className = 'wbox';
box.style.marginBottom = '4px';
box.appendChild(svg);
const labels = document.createElement('div');
labels.className = 'day-tl-labels';
labels.innerHTML = `<span>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
box.appendChild(labels);
const summary = document.createElement('div');
summary.className = 'quiet';
summary.style.marginTop = '4px';
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
box.appendChild(summary);
contentEl.innerHTML = '';
contentEl.appendChild(box);
if (btn) btn.disabled = false;
}
function applyFilters() {
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
const fromD = document.getElementById('filter-from').value;
@@ -1175,10 +1408,12 @@ document.getElementById('filter-clear').addEventListener('click', () => {
applyFilters();
});
// Seed threshold input from server config, then start
// Seed threshold 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.min_gap != null)
document.getElementById('min-gap-input').value = cfg.min_gap;
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
</script>
</body>
@@ -1199,6 +1434,8 @@ def main():
help='Bind address (default: 0.0.0.0)')
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
args = parser.parse_args()
rec_dir = Path(args.dir)
@@ -1208,6 +1445,7 @@ def main():
class Handler(_Handler):
recordings_dir = str(rec_dir.resolve())
threshold = args.threshold
min_gap = args.min_gap
server = ThreadingHTTPServer((args.host, args.port), Handler)