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:
@@ -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 0–1 (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 15–30 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.
|
||||
|
||||
|
||||
@@ -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 0–1 (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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user