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
+44 -2
View File
@@ -5,6 +5,7 @@ Records from multiple sources: Icecast streams and soundcards.
Supports time-based file splitting and concurrent recording. Supports time-based file splitting and concurrent recording.
""" """
import json
import os import os
import sys import sys
import time import time
@@ -787,6 +788,8 @@ class RecorderManager:
self.threads: List[threading.Thread] = [] self.threads: List[threading.Thread] = []
self.logger = None self.logger = None
self.audio_system = None self.audio_system = None
self.output_dir = 'recordings'
self._status_running = False
self._load_config() self._load_config()
@@ -812,6 +815,8 @@ class RecorderManager:
'log_file': config.get('general', 'log_file', fallback='recorder.log'), 'log_file': config.get('general', 'log_file', fallback='recorder.log'),
} }
self.output_dir = general['output_directory']
# Setup logging # Setup logging
self._setup_logging(general['log_level'], general['log_file']) self._setup_logging(general['log_level'], general['log_file'])
@@ -877,6 +882,32 @@ class RecorderManager:
) )
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def _write_status(self):
"""Write active filenames to status.json for the web UI to read."""
active = []
for r in self.recorders:
fn = r.current_filename
if fn:
try:
rel = os.path.relpath(fn, self.output_dir).replace('\\', '/')
active.append(rel)
except ValueError:
pass
try:
out = Path(self.output_dir)
out.mkdir(parents=True, exist_ok=True)
tmp = str(out / 'status.json.tmp')
with open(tmp, 'w') as fh:
json.dump({'active': active}, fh)
os.replace(tmp, str(out / 'status.json'))
except Exception:
pass
def _status_writer_loop(self):
while self._status_running:
self._write_status()
time.sleep(2)
def start(self): def start(self):
"""Start all recorders in separate threads.""" """Start all recorders in separate threads."""
if not self.recorders: if not self.recorders:
@@ -891,12 +922,15 @@ class RecorderManager:
self.threads.append(thread) self.threads.append(thread)
thread.start() thread.start()
self._status_running = True
st = threading.Thread(target=self._status_writer_loop, name='StatusWriter', daemon=True)
st.start()
self.logger.info("All recorders started") self.logger.info("All recorders started")
# Wait for interrupt # Wait for interrupt
try: try:
while True: while True:
# Check if all threads are still alive
alive = [t for t in self.threads if t.is_alive()] alive = [t for t in self.threads if t.is_alive()]
if not alive: if not alive:
self.logger.info("All recorders have stopped") self.logger.info("All recorders have stopped")
@@ -908,13 +942,21 @@ class RecorderManager:
def stop(self): def stop(self):
"""Stop all recorders gracefully.""" """Stop all recorders gracefully."""
self._status_running = False
for recorder in self.recorders: for recorder in self.recorders:
recorder.stop() recorder.stop()
# Wait for threads to finish
for thread in self.threads: for thread in self.threads:
thread.join(timeout=5) thread.join(timeout=5)
# Clear status file so the web UI shows no active recordings
try:
status_path = Path(self.output_dir) / 'status.json'
if status_path.exists():
status_path.unlink()
except Exception:
pass
self.logger.info("All recorders stopped") self.logger.info("All recorders stopped")
+400 -145
View File
@@ -2,8 +2,8 @@
""" """
ISR Web — Browse and download recorded audio files. ISR Web — Browse and download recorded audio files.
Shows a chronological table of all recordings, allows download, Shows a chronological table of all recordings, allows inline playback,
and analyses WAV files for loud sections using RMS. download, and analyses WAV/FLAC files for loud sections using RMS.
Usage: Usage:
python web.py # serves recordings/ on port 8080 python web.py # serves recordings/ on port 8080
@@ -16,6 +16,7 @@ import argparse
import json import json
import math import math
import os import os
import re
import struct import struct
import wave import wave
from datetime import datetime from datetime import datetime
@@ -29,6 +30,12 @@ try:
except ImportError: except ImportError:
NUMPY_AVAILABLE = False NUMPY_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # 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" 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 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): def _get_wav_info(path: Path):
@@ -52,9 +68,9 @@ def _get_wav_info(path: Path):
return None, None, None return None, None, None
def _compute_rms_windows(wf, channels: int, sampwidth: int, framerate: int, def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int): window_samples: int):
"""Yield (time_seconds, rms_0_to_1) for every window in the open wave file.""" """Yield rms_0_to_1 for every window in the open wave file."""
frame_pos = 0 frame_pos = 0
while True: while True:
raw = wf.readframes(window_samples) 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 mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0 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 frame_pos += window_samples
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES, def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float = LOUD_THRESHOLD): threshold: float) -> list:
""" sections = []
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 = []
start_t = None start_t = None
last_loud_t = None last_loud_t = None
@@ -125,26 +108,80 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
start_t = t start_t = t
last_loud_t = t last_loud_t = t
else: else:
if start_t is not None: if start_t is not None and (t - last_loud_t) > MIN_GAP_SECONDS:
gap = t - last_loud_t sections.append({'start': round(start_t, 1),
if gap > MIN_GAP_SECONDS: 'end': round(last_loud_t + window_dur, 1)})
sections.append({'start': round(start_t, 1), start_t = None
'end': round(last_loud_t + window_dur, 1)}) last_loud_t = None
start_t = None
last_loud_t = None
if start_t is not None: if start_t is not None:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)}) 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 { return {
'rms': rms_values, 'rms': rms_values,
'rms_display': rms_display, 'rms_display': rms_display,
'sections': sections, 'sections': _loud_sections(rms_values, window_dur, duration, threshold),
'duration': round(duration, 2), 'duration': round(duration, 2),
'window': round(window_dur, 4), '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 # File listing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -155,25 +192,35 @@ def list_files(recordings_dir: str):
if not base.exists(): if not base.exists():
return [] 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 = [] files = []
for path in base.rglob('*'): for path in base.rglob('*'):
if path.suffix.lower() not in AUDIO_EXTENSIONS: if path.suffix.lower() not in AUDIO_EXTENSIONS:
continue continue
stat = path.stat() stat = path.stat()
rel = path.relative_to(base) rel = str(path.relative_to(base)).replace('\\', '/')
duration = None
if path.suffix.lower() == '.wav': if path.suffix.lower() == '.wav':
duration, _, _ = _get_wav_info(path) duration, _, _ = _get_wav_info(path)
else:
duration = None
files.append({ files.append({
'name': str(rel).replace('\\', '/'), 'name': rel,
'size': stat.st_size, 'size': stat.st_size,
'mtime': stat.st_mtime, 'mtime': stat.st_mtime,
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), 'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration, 'duration': duration,
'ext': path.suffix.lower().lstrip('.'), 'ext': path.suffix.lower().lstrip('.'),
'recording': rel in active_files,
}) })
files.sort(key=lambda f: f['mtime'], reverse=True) files.sort(key=lambda f: f['mtime'], reverse=True)
@@ -188,8 +235,6 @@ class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings' recordings_dir: str = 'recordings'
threshold: float = LOUD_THRESHOLD threshold: float = LOUD_THRESHOLD
# ---- routing -----------------------------------------------------------
def do_GET(self): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
@@ -201,13 +246,15 @@ class _Handler(BaseHTTPRequestHandler):
self._api_files() self._api_files()
elif p == '/api/analyze': elif p == '/api/analyze':
self._api_analyze(qs) self._api_analyze(qs)
elif p == '/api/status':
self._api_status()
elif p.startswith('/download/'): elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):])) self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'):
self._stream(unquote(p[len('/stream/'):]))
else: else:
self._send(404, b'Not found', 'text/plain') self._send(404, b'Not found', 'text/plain')
# ---- endpoints ---------------------------------------------------------
def _html(self): def _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8') self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
@@ -225,13 +272,29 @@ class _Handler(BaseHTTPRequestHandler):
if path is None: if path is None:
return return
if path.suffix.lower() != '.wav': ext = path.suffix.lower()
self._json_err(400, 'loudness analysis is only available for WAV files') 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 return
result = analyze_wav(path, threshold=self.threshold) self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
data = json.dumps(result).encode('utf-8')
self._send(200, data, '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): def _download(self, filename: str):
path = self._safe_path(filename) path = self._safe_path(filename)
@@ -252,14 +315,61 @@ class _Handler(BaseHTTPRequestHandler):
break break
self.wfile.write(chunk) 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): def _safe_path(self, filename: str):
"""Resolve filename within recordings_dir; return None and send error if invalid."""
base = Path(self.recordings_dir).resolve() base = Path(self.recordings_dir).resolve()
try: try:
path = (base / filename).resolve() path = (base / filename).resolve()
path.relative_to(base) # raises ValueError if outside base path.relative_to(base)
except (ValueError, Exception): except (ValueError, Exception):
self._send(403, b'Forbidden', 'text/plain') self._send(403, b'Forbidden', 'text/plain')
return None return None
@@ -281,11 +391,11 @@ class _Handler(BaseHTTPRequestHandler):
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json') self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
def log_message(self, fmt, *args): 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> _HTML = r"""<!DOCTYPE html>
@@ -296,180 +406,323 @@ _HTML = r"""<!DOCTYPE html>
<title>ISR Archive</title> <title>ISR Archive</title>
<style> <style>
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491; :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} *{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif} 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} 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} .wrap{padding:20px 28px}
table{width:100%;border-collapse:collapse} table{width:100%;border-collapse:collapse}
th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px; 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} td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
tr:last-child td{border-bottom:none} tr.data-row:hover td{background:var(--surf)}
tr:hover td{background:var(--surf)} .fn{font-family:ui-monospace,monospace;font-size:12px}
.fn{font-family:ui-monospace,monospace;font-size:12px;color:var(--txt)}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px; .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-wav{color:var(--green);border-color:#166534;background:#052e16}
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40} .badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157} .badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04} .badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)} .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)} .muted{color:var(--muted)}
btn,button{cursor:pointer;border:1px solid var(--brd);background:var(--surf); 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} color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
button:hover{background:var(--brd)} 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{color:var(--accent);text-decoration:none;font-size:13px}
a.dl:hover{text-decoration:underline} a.dl:hover{text-decoration:underline}
/* waveform row */ a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
.wrow td{padding:0 10px 14px;background:var(--bg)} .actions{display:flex;gap:6px;align-items:center}
/* waveform */
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px} .wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
svg.wave{display:block;width:100%;height:56px} svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px} .chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px; .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} .quiet{color:var(--muted);font-size:12px;margin-top:6px}
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0} .spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty{text-align:center;padding:60px;color:var(--muted)} .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> </style>
</head> </head>
<body> <body>
<a href="#main" class="skip">Skip to content</a>
<header> <header>
<h1>ISR Archive</h1> <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> </header>
<div class="wrap"> <div class="wrap" id="main">
<table> <table aria-label="Recordings archive">
<thead> <thead>
<tr> <tr>
<th>File</th> <th scope="col">File</th>
<th>Date</th> <th scope="col">Date</th>
<th>Duration</th> <th scope="col">Duration</th>
<th>Size</th> <th scope="col">Size</th>
<th>Waveform / Loud sections</th> <th scope="col">Loudness</th>
<th></th> <th scope="col"><span class="sr">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody id="tbody"></tbody> <tbody id="tbody"></tbody>
</table> </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> </div>
<script> <script>
const esc = s => String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const fmtDur = s => { const fmtDur = s => {
if (s == null) return ''; if (s == null) return '';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60); 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 fmtSize = b => { const fmtSize = b => {
if (b < 1024) return b+' B'; if (b<1024) return b+' B';
if (b < 1<<20) return (b/1024).toFixed(0)+' KB'; if (b<1<<20) return (b/1024).toFixed(0)+' KB';
if (b < 1<<30) return (b/(1<<20)).toFixed(1)+' MB'; if (b<1<<30) return (b/(1<<20)).toFixed(1)+' MB';
return (b/(1<<30)).toFixed(2)+' GB'; return (b/(1<<30)).toFixed(2)+' GB';
}; };
const fmtT = s => { const fmtT = s => {
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60); 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) { // idx -> filename, for live-status polling
const ns = 'http://www.w3.org/2000/svg'; const recMap = new Map();
const svg = document.createElementNS(ns, 'svg');
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('class','wave');
svg.setAttribute('viewBox', `0 0 ${rms.length} 1`); svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
svg.setAttribute('preserveAspectRatio','none'); 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 && sections) {
if (duration > 0) {
sections.forEach(s => { 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('x', (s.start/duration)*rms.length);
r.setAttribute('y', 0); r.setAttribute('y', 0);
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length); r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
r.setAttribute('height', 1); r.setAttribute('height', 1);
r.setAttribute('fill','rgba(249,115,22,0.22)'); r.setAttribute('fill','rgba(249,115,22,0.22)');
r.setAttribute('aria-hidden','true');
svg.appendChild(r); svg.appendChild(r);
}); });
} }
const maxV = Math.max(...rms, 0.001); const maxV = Math.max(...rms, 0.001);
rms.forEach((v,i) => { rms.forEach((v,i) => {
const h = v/maxV; const h = v/maxV;
const r = document.createElementNS(ns,'rect'); 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('width',1); r.setAttribute('height',h);
r.setAttribute('fill','#4f9cf9'); r.setAttribute('fill','#4f9cf9');
r.setAttribute('aria-hidden','true');
svg.appendChild(r); svg.appendChild(r);
}); });
return svg; return svg;
} }
async function analyse(filename, cell, btn) { async function analyse(filename, cell, btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = ''; btn.textContent = '';
cell.innerHTML = '<div class="spin">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
try { try {
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)); const r = await fetch('/api/analyze?file='+encodeURIComponent(filename));
const d = await r.json(); 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'; 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) { if (d.sections && d.sections.length) {
d.sections.forEach(s => { d.sections.forEach(s => {
const c=document.createElement('span'); c.className='chip'; const c = document.createElement('span');
c.textContent=`${fmtT(s.start)} ${fmtT(s.end)}`; c.className='chip'; c.setAttribute('role','listitem');
c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`;
chips.appendChild(c); chips.appendChild(c);
}); });
} else { } 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); box.appendChild(chips);
cell.innerHTML=''; cell.appendChild(box); cell.innerHTML=''; cell.appendChild(box);
} catch(e) { } 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() { async function load() {
const files = await (await fetch('/api/files')).json(); const refreshBtn = document.getElementById('refresh-btn');
const tbody = document.getElementById('tbody'); refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = document.getElementById('subtitle').textContent = 'Loading…';
`${files.length} recording${files.length!==1?'s':''} found`; recMap.clear();
if (!files.length) { document.getElementById('empty').style.display=''; return; }
files.forEach(f => { let files;
const ext = f.ext; 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 tr = document.createElement('tr');
const waveCell = ext==='wav' tr.className = 'data-row';
? `<td class="wave-cell"></td>` tr.id = 'row-'+i;
: `<td><span class="muted" style="font-size:12px">WAV files only</span></td>`;
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 = ` tr.innerHTML = `
<td><span class="badge badge-${ext}">${ext}</span><span class="fn">${f.name}</span></td> <td>
<td class="muted" style="white-space:nowrap">${f.date}</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 style="white-space:nowrap">${fmtDur(f.duration)}</td>
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td> <td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
${waveCell} <td id="wave-${i}">${canAnalyse ? '' :
<td><a class="dl" href="/download/${encodeURIComponent(f.name)}">&#8659; Download</a></td>`; '<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); tbody.appendChild(tr);
if (ext === 'wav') { // ---- player row (hidden by default) ----
const cell = tr.querySelector('.wave-cell'); const prow = document.createElement('tr');
const btn = document.createElement('button'); prow.className = 'player-row';
btn.textContent = 'Analyse'; prow.id = 'prow-'+i;
btn.addEventListener('click', () => analyse(f.name, cell, btn)); prow.hidden = true;
cell.appendChild(btn); 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> </script>
</body> </body>
</html>""" </html>"""
@@ -505,7 +758,9 @@ def main():
print(f"Recordings dir → {rec_dir.resolve()}") print(f"Recordings dir → {rec_dir.resolve()}")
print(f"Loud threshold → {args.threshold}") print(f"Loud threshold → {args.threshold}")
if not NUMPY_AVAILABLE: 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") print("Stop with Ctrl+C\n")
try: try: