Files
ISR/web.py
T
admin 624f1f2664 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
2026-04-26 12:53:01 +02:00

774 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
ISR Web — Browse and download recorded audio files.
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
python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05)
"""
import argparse
import json
import math
import os
import re
import struct
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
try:
import numpy as np
NUMPY_AVAILABLE = True
except ImportError:
NUMPY_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
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 analysis helpers
# ---------------------------------------------------------------------------
def _get_wav_info(path: Path):
"""Return (duration_seconds, sample_rate, channels) or (None, None, None)."""
try:
with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate(), wf.getframerate(), wf.getnchannels()
except Exception:
return None, None, None
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)
if not raw:
break
n_samp = len(raw) // (sampwidth * channels)
if n_samp == 0:
break
if NUMPY_AVAILABLE:
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
if channels > 1:
arr = arr.reshape(-1, channels).mean(axis=1)
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
else:
fmt = f'<{n_samp * channels}h'
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield round(rms, 5)
frame_pos += window_samples
def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float) -> list:
sections = []
start_t = None
last_loud_t = None
for i, rms in enumerate(rms_values):
t = i * window_dur
if rms >= threshold:
if start_t is None:
start_t = t
last_loud_t = t
else:
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': _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
# ---------------------------------------------------------------------------
def list_files(recordings_dir: str):
"""Return list of audio file metadata dicts, sorted newest first."""
base = Path(recordings_dir)
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 = str(path.relative_to(base)).replace('\\', '/')
duration = None
if path.suffix.lower() == '.wav':
duration, _, _ = _get_wav_info(path)
files.append({
'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)
return files
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings'
threshold: float = LOUD_THRESHOLD
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
p = parsed.path
if p == '/':
self._html()
elif p == '/api/files':
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')
def _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
def _api_files(self):
data = json.dumps(list_files(self.recordings_dir)).encode('utf-8')
self._send(200, data, 'application/json')
def _api_analyze(self, qs):
filename = qs.get('file', [None])[0]
if not filename:
self._json_err(400, 'missing file parameter')
return
path = self._safe_path(filename)
if path is None:
return
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
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)
if path is None:
return
size = path.stat().st_size
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', f'attachment; filename="{path.name}"')
self.send_header('Content-Length', str(size))
self.end_headers()
with open(path, 'rb') as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
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):
base = Path(self.recordings_dir).resolve()
try:
path = (base / filename).resolve()
path.relative_to(base)
except (ValueError, Exception):
self._send(403, b'Forbidden', 'text/plain')
return None
if not path.exists():
self._send(404, b'Not found', 'text/plain')
return None
return path
def _send(self, code: int, body: bytes, content_type: str):
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json_err(self, code: int, msg: str):
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
def log_message(self, fmt, *args):
pass
# ---------------------------------------------------------------------------
# Embedded HTML / CSS / JS
# ---------------------------------------------------------------------------
_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ISR Archive</title>
<style>
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
--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}
/* 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;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}
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
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: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)}
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}
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}
.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" aria-live="polite" aria-atomic="true">Loading…</span>
<button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
</header>
<div class="wrap" id="main">
<table aria-label="Recordings archive">
<thead>
<tr>
<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" 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}:${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';
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}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
};
const pad = n => String(n).padStart(2,'0');
// 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('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':''}`);
if (duration > 0 && sections) {
sections.forEach(s => {
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('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" 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" 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, filename));
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.setAttribute('role','listitem');
c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`;
chips.appendChild(c);
});
} else {
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" role="alert">Error: ${esc(e.message)}</div>`;
btn.disabled = false; btn.textContent = 'Analyse';
}
}
async function load() {
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = 'Loading…';
recMap.clear();
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');
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-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
</td>
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
<td id="wave-${i}">${canAnalyse ? '' :
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
<td>
<div class="actions">
<button id="pbtn-${i}"
aria-expanded="false"
aria-controls="prow-${i}"
aria-label="Play ${esc(f.name)}">▶ Play</button>
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
aria-label="Download ${esc(f.name)}">↓ Download</a>
</div>
</td>`;
tbody.appendChild(tr);
// ---- 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;
}
// 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>"""
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description='ISR Web — audio archive browser')
parser.add_argument('--dir', default='recordings',
help='Recordings directory (default: recordings)')
parser.add_argument('--port', type=int, default=8080,
help='HTTP port (default: 8080)')
parser.add_argument('--host', default='0.0.0.0',
help='Bind address (default: 0.0.0.0)')
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
args = parser.parse_args()
rec_dir = Path(args.dir)
if not rec_dir.exists():
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
class Handler(_Handler):
recordings_dir = str(rec_dir.resolve())
threshold = args.threshold
server = HTTPServer((args.host, args.port), Handler)
print(f"ISR Web running → http://{args.host}:{args.port}/")
print(f"Recordings dir → {rec_dir.resolve()}")
print(f"Loud threshold → {args.threshold}")
if not NUMPY_AVAILABLE:
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:
server.serve_forever()
except KeyboardInterrupt:
print("Stopped.")
if __name__ == '__main__':
main()