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:
@@ -5,6 +5,7 @@ Records from multiple sources: Icecast streams and soundcards.
|
||||
Supports time-based file splitting and concurrent recording.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
@@ -787,6 +788,8 @@ class RecorderManager:
|
||||
self.threads: List[threading.Thread] = []
|
||||
self.logger = None
|
||||
self.audio_system = None
|
||||
self.output_dir = 'recordings'
|
||||
self._status_running = False
|
||||
|
||||
self._load_config()
|
||||
|
||||
@@ -812,6 +815,8 @@ class RecorderManager:
|
||||
'log_file': config.get('general', 'log_file', fallback='recorder.log'),
|
||||
}
|
||||
|
||||
self.output_dir = general['output_directory']
|
||||
|
||||
# Setup logging
|
||||
self._setup_logging(general['log_level'], general['log_file'])
|
||||
|
||||
@@ -877,6 +882,32 @@ class RecorderManager:
|
||||
)
|
||||
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):
|
||||
"""Start all recorders in separate threads."""
|
||||
if not self.recorders:
|
||||
@@ -891,12 +922,15 @@ class RecorderManager:
|
||||
self.threads.append(thread)
|
||||
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")
|
||||
|
||||
# Wait for interrupt
|
||||
try:
|
||||
while True:
|
||||
# Check if all threads are still alive
|
||||
alive = [t for t in self.threads if t.is_alive()]
|
||||
if not alive:
|
||||
self.logger.info("All recorders have stopped")
|
||||
@@ -908,13 +942,21 @@ class RecorderManager:
|
||||
|
||||
def stop(self):
|
||||
"""Stop all recorders gracefully."""
|
||||
self._status_running = False
|
||||
for recorder in self.recorders:
|
||||
recorder.stop()
|
||||
|
||||
# Wait for threads to finish
|
||||
for thread in self.threads:
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -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 0–1 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">↻ 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
|
||||
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)}">⇓ 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:
|
||||
|
||||
Reference in New Issue
Block a user