feat: FLAC analysis, inline player, WCAG, live-recording status

web.py:
- Extend loudness analysis to FLAC files via soundfile (numpy required)
- Add /stream/ endpoint with HTTP Range support for seekable inline playback
- Add collapsible ▶ Play button per row (hidden by default); src loaded lazily
- Add /api/status endpoint returning active filenames from status.json
- Animated ● REC badge on in-progress files, polled every 5 s
- Full WCAG: skip link, aria-expanded/controls, aria-label, role=img on
  waveform SVG, role=list on loud-section chips, focus-visible outlines,
  aria-live on subtitle, focus moved to <audio> when player opens

isr.py:
- Write recordings/status.json atomically every 2 s while recording
- Delete status.json on clean shutdown so web UI shows no stale state
This commit is contained in:
2026-04-26 12:53:01 +02:00
parent 8254ccde86
commit 624f1f2664
2 changed files with 444 additions and 147 deletions
+400 -145
View File
@@ -2,8 +2,8 @@
"""
ISR Web — Browse and download recorded audio files.
Shows a chronological table of all recordings, allows download,
and analyses WAV files for loud sections using RMS.
Shows a chronological table of all recordings, allows inline playback,
download, and analyses WAV/FLAC files for loud sections using RMS.
Usage:
python web.py # serves recordings/ on port 8080
@@ -16,6 +16,7 @@ import argparse
import json
import math
import os
import re
import struct
import wave
from datetime import datetime
@@ -29,6 +30,12 @@ try:
except ImportError:
NUMPY_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -38,9 +45,18 @@ WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
LOUD_THRESHOLD = 0.05 # RMS 01 scale; sections above this are "interesting"
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
MIME_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.opus': 'audio/ogg',
}
# ---------------------------------------------------------------------------
# Audio helpers
# Audio analysis helpers
# ---------------------------------------------------------------------------
def _get_wav_info(path: Path):
@@ -52,9 +68,9 @@ def _get_wav_info(path: Path):
return None, None, None
def _compute_rms_windows(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int):
"""Yield (time_seconds, rms_0_to_1) for every window in the open wave file."""
def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int):
"""Yield rms_0_to_1 for every window in the open wave file."""
frame_pos = 0
while True:
raw = wf.readframes(window_samples)
@@ -75,46 +91,13 @@ def _compute_rms_windows(wf, channels: int, sampwidth: int, framerate: int,
mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield frame_pos / framerate, round(rms, 5)
yield round(rms, 5)
frame_pos += window_samples
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD):
"""
Analyse a WAV file.
Returns a dict with:
rms — full list of RMS values (one per window)
rms_display — downsampled to ≤800 points for the sparkline
sections — list of {start, end} dicts for loud passages
duration — total seconds
window — window duration in seconds
"""
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
rms_values = [rms for _, rms in
_compute_rms_windows(wf, channels, sampwidth, framerate, window_samples)]
except Exception as e:
return {'error': str(e)}
window_dur = window_samples / framerate
duration = n_frames / framerate
# Downsample for the sparkline (max 800 bars)
if len(rms_values) > 800:
step = len(rms_values) / 800
rms_display = [rms_values[int(i * step)] for i in range(800)]
else:
rms_display = rms_values
# Find loud sections
sections: list = []
def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float) -> list:
sections = []
start_t = None
last_loud_t = None
@@ -125,26 +108,80 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
start_t = t
last_loud_t = t
else:
if start_t is not None:
gap = t - last_loud_t
if gap > MIN_GAP_SECONDS:
sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1)})
start_t = None
last_loud_t = None
if start_t is not None and (t - last_loud_t) > MIN_GAP_SECONDS:
sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1)})
start_t = None
last_loud_t = None
if start_t is not None:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)})
return sections
def _package_result(rms_values: list, framerate: int, n_frames: int,
window_samples: int, threshold: float) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
if len(rms_values) > 800:
step = len(rms_values) / 800
rms_display = [rms_values[int(i * step)] for i in range(800)]
else:
rms_display = rms_values
return {
'rms': rms_values,
'rms_display': rms_display,
'sections': sections,
'sections': _loud_sections(rms_values, window_dur, duration, threshold),
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD) -> dict:
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
rms_values = list(_compute_rms_windows_wav(
wf, channels, sampwidth, framerate, window_samples))
except Exception as e:
return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, threshold)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD) -> 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'}
try:
with sf.SoundFile(path) as f:
framerate = f.samplerate
channels = f.channels
n_frames = len(f)
rms_values = []
while True:
frames = f.read(window_samples, dtype='float32', always_2d=True)
if len(frames) == 0:
break
mono = frames.mean(axis=1) if channels > 1 else frames[:, 0]
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
rms_values.append(round(rms, 5))
except Exception as e:
return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, threshold)
# ---------------------------------------------------------------------------
# File listing
# ---------------------------------------------------------------------------
@@ -155,25 +192,35 @@ def list_files(recordings_dir: str):
if not base.exists():
return []
# Load active recordings written by isr.py
active_files: set = set()
status_path = base / 'status.json'
if status_path.exists():
try:
with open(status_path) as fh:
active_files = set(json.load(fh).get('active', []))
except Exception:
pass
files = []
for path in base.rglob('*'):
if path.suffix.lower() not in AUDIO_EXTENSIONS:
continue
stat = path.stat()
rel = path.relative_to(base)
rel = str(path.relative_to(base)).replace('\\', '/')
duration = None
if path.suffix.lower() == '.wav':
duration, _, _ = _get_wav_info(path)
else:
duration = None
files.append({
'name': str(rel).replace('\\', '/'),
'size': stat.st_size,
'mtime': stat.st_mtime,
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'ext': path.suffix.lower().lstrip('.'),
'name': rel,
'size': stat.st_size,
'mtime': stat.st_mtime,
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'ext': path.suffix.lower().lstrip('.'),
'recording': rel in active_files,
})
files.sort(key=lambda f: f['mtime'], reverse=True)
@@ -188,8 +235,6 @@ class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings'
threshold: float = LOUD_THRESHOLD
# ---- routing -----------------------------------------------------------
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
@@ -201,13 +246,15 @@ class _Handler(BaseHTTPRequestHandler):
self._api_files()
elif p == '/api/analyze':
self._api_analyze(qs)
elif p == '/api/status':
self._api_status()
elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'):
self._stream(unquote(p[len('/stream/'):]))
else:
self._send(404, b'Not found', 'text/plain')
# ---- endpoints ---------------------------------------------------------
def _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
@@ -225,13 +272,29 @@ class _Handler(BaseHTTPRequestHandler):
if path is None:
return
if path.suffix.lower() != '.wav':
self._json_err(400, 'loudness analysis is only available for WAV files')
ext = path.suffix.lower()
if ext == '.wav':
result = analyze_wav(path, threshold=self.threshold)
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=self.threshold)
else:
self._json_err(400, f'Loudness analysis is not available for {ext} files')
return
result = analyze_wav(path, threshold=self.threshold)
data = json.dumps(result).encode('utf-8')
self._send(200, data, 'application/json')
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
def _api_status(self):
status_path = Path(self.recordings_dir) / 'status.json'
if status_path.exists():
try:
self._send(200, status_path.read_bytes(), 'application/json')
return
except Exception:
pass
self._send(200, b'{"active":[]}', 'application/json')
def _download(self, filename: str):
path = self._safe_path(filename)
@@ -252,14 +315,61 @@ class _Handler(BaseHTTPRequestHandler):
break
self.wfile.write(chunk)
# ---- helpers -----------------------------------------------------------
def _stream(self, filename: str):
"""Serve audio for inline playback with HTTP Range support."""
path = self._safe_path(filename)
if path is None:
return
content_type = MIME_TYPES.get(path.suffix.lower(), 'application/octet-stream')
size = path.stat().st_size
range_header = self.headers.get('Range', '')
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
if m:
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else size - 1
end = min(end, size - 1)
if start > end or start >= size:
self._send(416, b'Range Not Satisfiable', 'text/plain')
return
length = end - start + 1
self.send_response(206)
self.send_header('Content-Type', content_type)
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
self.send_header('Content-Length', str(length))
self.send_header('Accept-Ranges', 'bytes')
self.end_headers()
with open(path, 'rb') as fh:
fh.seek(start)
remaining = length
while remaining > 0:
chunk = fh.read(min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
else:
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(size))
self.send_header('Accept-Ranges', 'bytes')
self.end_headers()
with open(path, 'rb') as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
def _safe_path(self, filename: str):
"""Resolve filename within recordings_dir; return None and send error if invalid."""
base = Path(self.recordings_dir).resolve()
try:
path = (base / filename).resolve()
path.relative_to(base) # raises ValueError if outside base
path.relative_to(base)
except (ValueError, Exception):
self._send(403, b'Forbidden', 'text/plain')
return None
@@ -281,11 +391,11 @@ class _Handler(BaseHTTPRequestHandler):
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
def log_message(self, fmt, *args):
pass # suppress default access log noise
pass
# ---------------------------------------------------------------------------
# Embedded HTML/CSS/JS
# Embedded HTML / CSS / JS
# ---------------------------------------------------------------------------
_HTML = r"""<!DOCTYPE html>
@@ -296,180 +406,323 @@ _HTML = r"""<!DOCTYPE html>
<title>ISR Archive</title>
<style>
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;}
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
header{padding:20px 28px;border-bottom:1px solid var(--brd);display:flex;align-items:baseline;gap:12px}
/* skip link */
.skip{position:absolute;left:-999px;top:0;padding:6px 14px;background:var(--accent);
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-size:13px;font-weight:600}
.skip:focus{left:0}
/* sr-only */
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
header{padding:20px 28px;border-bottom:1px solid var(--brd);
display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
header h1{font-size:18px;font-weight:600}
#subtitle{color:var(--muted);font-size:13px}
#subtitle{color:var(--muted);font-size:13px;margin-right:auto}
.wrap{padding:20px 28px}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px;
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);white-space:nowrap}
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);
white-space:nowrap}
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surf)}
.fn{font-family:ui-monospace,monospace;font-size:12px;color:var(--txt)}
tr.data-row:hover td{background:var(--surf)}
.fn{font-family:ui-monospace,monospace;font-size:12px}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:700;text-transform:uppercase;margin-right:5px;border:1px solid}
font-weight:700;text-transform:uppercase;margin-right:4px;border:1px solid}
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
.badge-rec{display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:3px;
font-size:10px;font-weight:700;text-transform:uppercase;margin-right:4px;
color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
.muted{color:var(--muted)}
btn,button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
color:var(--txt);padding:4px 11px;border-radius:5px;font-size:12px;white-space:nowrap}
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
button:hover{background:var(--brd)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button:disabled{opacity:.5;cursor:default}
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
a.dl:hover{text-decoration:underline}
/* waveform row */
.wrow td{padding:0 10px 14px;background:var(--bg)}
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
.actions{display:flex;gap:6px;align-items:center}
/* waveform */
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty{text-align:center;padding:60px;color:var(--muted)}
/* player row */
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
audio{width:100%;height:36px;border-radius:4px;display:block;
color-scheme:dark;accent-color:var(--accent)}
</style>
</head>
<body>
<a href="#main" class="skip">Skip to content</a>
<header>
<h1>ISR Archive</h1>
<span id="subtitle">Loading…</span>
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
<button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
</header>
<div class="wrap">
<table>
<div class="wrap" id="main">
<table aria-label="Recordings archive">
<thead>
<tr>
<th>File</th>
<th>Date</th>
<th>Duration</th>
<th>Size</th>
<th>Waveform / Loud sections</th>
<th></th>
<th scope="col">File</th>
<th scope="col">Date</th>
<th scope="col">Duration</th>
<th scope="col">Size</th>
<th scope="col">Loudness</th>
<th scope="col"><span class="sr">Actions</span></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" style="display:none">No recordings found.</div>
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
</div>
<script>
const esc = s => String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const fmtDur = s => {
if (s == null) return '';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
return h ? `${h}:${p(m)}:${p(sec)}` : `${m}:${p(sec)}`;
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
};
const fmtSize = b => {
if (b < 1024) return b+' B';
if (b < 1<<20) return (b/1024).toFixed(0)+' KB';
if (b < 1<<30) return (b/(1<<20)).toFixed(1)+' MB';
if (b<1024) return b+' B';
if (b<1<<20) return (b/1024).toFixed(0)+' KB';
if (b<1<<30) return (b/(1<<20)).toFixed(1)+' MB';
return (b/(1<<30)).toFixed(2)+' GB';
};
const fmtT = s => {
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`;
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
};
const p = n => String(n).padStart(2,'0');
const pad = n => String(n).padStart(2,'0');
function drawWave(rms, sections, duration) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
// idx -> filename, for live-status polling
const recMap = new Map();
function togglePlayer(idx, filename) {
const prow = document.getElementById('prow-'+idx);
const btn = document.getElementById('pbtn-'+idx);
const audio = document.getElementById('aud-'+idx);
const open = btn.getAttribute('aria-expanded') === 'true';
if (!open) {
if (!audio.getAttribute('data-src-set')) {
audio.src = '/stream/' + encodeURIComponent(filename);
audio.setAttribute('data-src-set','1');
}
prow.hidden = false;
btn.setAttribute('aria-expanded','true');
btn.textContent = '⏹ Hide';
btn.setAttribute('aria-label','Hide player for '+filename);
// Move focus to audio control so keyboard users can operate it immediately
audio.focus();
} else {
audio.pause();
prow.hidden = true;
btn.setAttribute('aria-expanded','false');
btn.textContent = '▶ Play';
btn.setAttribute('aria-label','Play '+filename);
}
}
function drawWave(rms, sections, duration, filename) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns,'svg');
svg.setAttribute('class','wave');
svg.setAttribute('viewBox', `0 0 ${rms.length} 1`);
svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
svg.setAttribute('preserveAspectRatio','none');
svg.setAttribute('role','img');
const nSec = sections ? sections.length : 0;
svg.setAttribute('aria-label',
`Waveform for ${filename}: duration ${fmtDur(duration)}, ${nSec} loud section${nSec!==1?'s':''}`);
// highlight loud sections
if (duration > 0) {
if (duration > 0 && sections) {
sections.forEach(s => {
const r = document.createElementNS(ns, 'rect');
const r = document.createElementNS(ns,'rect');
r.setAttribute('x', (s.start/duration)*rms.length);
r.setAttribute('y', 0);
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
r.setAttribute('height', 1);
r.setAttribute('fill','rgba(249,115,22,0.22)');
r.setAttribute('aria-hidden','true');
svg.appendChild(r);
});
}
const maxV = Math.max(...rms, 0.001);
rms.forEach((v,i) => {
const h = v/maxV;
const r = document.createElementNS(ns,'rect');
r.setAttribute('x', i); r.setAttribute('y', 1-h);
r.setAttribute('x',i); r.setAttribute('y',1-h);
r.setAttribute('width',1); r.setAttribute('height',h);
r.setAttribute('fill','#4f9cf9');
r.setAttribute('aria-hidden','true');
svg.appendChild(r);
});
return svg;
}
async function analyse(filename, cell, btn) {
btn.disabled = true;
btn.textContent = '';
cell.innerHTML = '<div class="spin">Analysing…</div>';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
try {
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename));
const d = await r.json();
if (d.error) { cell.innerHTML=`<div class="spin">Error: ${d.error}</div>`; return; }
if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
btn.disabled = false; btn.textContent = 'Analyse';
return;
}
const box = document.createElement('div'); box.className='wbox';
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0));
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
const chips = document.createElement('div'); chips.className='chips';
const chips = document.createElement('div');
chips.className='chips';
chips.setAttribute('role','list');
chips.setAttribute('aria-label','Loud sections');
if (d.sections && d.sections.length) {
d.sections.forEach(s => {
const c=document.createElement('span'); c.className='chip';
c.textContent=`${fmtT(s.start)} ${fmtT(s.end)}`;
const c = document.createElement('span');
c.className='chip'; c.setAttribute('role','listitem');
c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`;
chips.appendChild(c);
});
} else {
chips.innerHTML='<span class="quiet">No loud sections</span>';
const q = document.createElement('span');
q.className='quiet'; q.setAttribute('role','listitem');
q.textContent='No loud sections found';
chips.appendChild(q);
}
box.appendChild(chips);
cell.innerHTML=''; cell.appendChild(box);
} catch(e) {
cell.innerHTML=`<div class="spin">Error: ${e.message}</div>`;
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`;
btn.disabled = false; btn.textContent = 'Analyse';
}
}
async function load() {
const files = await (await fetch('/api/files')).json();
const tbody = document.getElementById('tbody');
document.getElementById('subtitle').textContent =
`${files.length} recording${files.length!==1?'s':''} found`;
if (!files.length) { document.getElementById('empty').style.display=''; return; }
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = 'Loading…';
recMap.clear();
files.forEach(f => {
const ext = f.ext;
let files;
try {
files = await (await fetch('/api/files')).json();
} catch(e) {
document.getElementById('subtitle').textContent = 'Error loading files';
refreshBtn.disabled = false;
return;
}
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
const n = files.length;
document.getElementById('subtitle').textContent =
`${n} recording${n!==1?'s':''} found`;
document.getElementById('empty').style.display = n ? 'none' : '';
if (!n) { refreshBtn.disabled = false; return; }
files.forEach((f, i) => {
const ext = f.ext;
const canAnalyse = ext === 'wav' || ext === 'flac';
const isRec = !!f.recording;
// ---- main data row ----
const tr = document.createElement('tr');
const waveCell = ext==='wav'
? `<td class="wave-cell"></td>`
: `<td><span class="muted" style="font-size:12px">WAV files only</span></td>`;
tr.className = 'data-row';
tr.id = 'row-'+i;
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-${ext}">${ext}</span><span class="fn">${f.name}</span></td>
<td class="muted" style="white-space:nowrap">${f.date}</td>
<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>
${waveCell}
<td><a class="dl" href="/download/${encodeURIComponent(f.name)}">&#8659; Download</a></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>
</div>
</td>`;
tbody.appendChild(tr);
if (ext === 'wav') {
const cell = tr.querySelector('.wave-cell');
const btn = document.createElement('button');
btn.textContent = 'Analyse';
btn.addEventListener('click', () => analyse(f.name, cell, btn));
cell.appendChild(btn);
// ---- player row (hidden by default) ----
const prow = document.createElement('tr');
prow.className = 'player-row';
prow.id = 'prow-'+i;
prow.hidden = true;
prow.innerHTML = `<td colspan="6">
<audio id="aud-${i}" controls preload="none"
aria-label="Playback: ${esc(f.name)}"></audio>
</td>`;
tbody.appendChild(prow);
// ---- 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}`);
abtn.addEventListener('click', () => analyse(f.name, cell, abtn));
cell.appendChild(abtn);
}
// ---- attach play button handler ----
document.getElementById('pbtn-'+i)
.addEventListener('click', () => togglePlayer(i, f.name));
// ---- register for live-status polling ----
recMap.set(i, f.name);
});
refreshBtn.disabled = false;
}
load();
// Poll recording status every 5 s to update REC badges
async function pollStatus() {
try {
const s = await (await fetch('/api/status')).json();
const active = new Set(s.active || []);
recMap.forEach((filename, idx) => {
const badge = document.getElementById('rec-'+idx);
if (!badge) return;
const on = active.has(filename);
badge.hidden = !on;
badge.setAttribute('aria-hidden', on ? 'false' : 'true');
});
} catch(e) {}
}
document.getElementById('refresh-btn').addEventListener('click', load);
load().then(() => setInterval(pollStatus, 5000));
</script>
</body>
</html>"""
@@ -505,7 +758,9 @@ def main():
print(f"Recordings dir → {rec_dir.resolve()}")
print(f"Loud threshold → {args.threshold}")
if not NUMPY_AVAILABLE:
print("Note: numpy not installed — RMS analysis uses pure Python (slower for large files)")
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
elif not SOUNDFILE_AVAILABLE:
print("Note: soundfile not installed — FLAC loudness analysis unavailable")
print("Stop with Ctrl+C\n")
try: