d583620f8c
Analysis chips are now buttons. Clicking one opens the player (if not already open) and seeks to the section start. J skips to the previous loud section, K to the next. Shortcuts are suppressed when focus is inside an input field.
989 lines
36 KiB
Python
989 lines
36 KiB
Python
#!/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 shutil
|
||
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 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 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 _get_audio_duration(path: Path):
|
||
"""Return duration in seconds for any supported audio file, or None."""
|
||
ext = path.suffix.lower()
|
||
if ext == '.wav':
|
||
dur, _, _ = _get_wav_info(path)
|
||
return dur
|
||
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
|
||
try:
|
||
with sf.SoundFile(path) as f:
|
||
return round(len(f) / f.samplerate, 2)
|
||
except Exception:
|
||
return None
|
||
return 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('\\', '/')
|
||
is_active = rel in active_files
|
||
|
||
# Skip reading partial headers for in-progress files — the WAV nframes
|
||
# field and FLAC total_samples are both unfinalized while recording,
|
||
# producing wildly incorrect values (e.g. 53375995583:39:01).
|
||
duration = None if is_active else _get_audio_duration(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': is_active,
|
||
})
|
||
|
||
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_DELETE(self):
|
||
parsed = urlparse(self.path)
|
||
p = parsed.path
|
||
if p.startswith('/api/files/'):
|
||
self._api_delete(unquote(p[len('/api/files/'):]))
|
||
else:
|
||
self._send(404, b'Not found', 'text/plain')
|
||
|
||
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 == '/api/storage':
|
||
self._api_storage()
|
||
elif p == '/api/config':
|
||
self._api_config()
|
||
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
|
||
|
||
try:
|
||
threshold = float(qs.get('threshold', [self.threshold])[0])
|
||
threshold = max(0.0, min(1.0, threshold))
|
||
except (ValueError, TypeError):
|
||
threshold = self.threshold
|
||
|
||
status_path = Path(self.recordings_dir) / 'status.json'
|
||
try:
|
||
with open(status_path) as fh:
|
||
if filename in set(json.load(fh).get('active', [])):
|
||
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
ext = path.suffix.lower()
|
||
if ext == '.wav':
|
||
result = analyze_wav(path, threshold=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=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 _api_storage(self):
|
||
base = Path(self.recordings_dir)
|
||
used = 0
|
||
if base.exists():
|
||
used = sum(
|
||
p.stat().st_size
|
||
for p in base.rglob('*')
|
||
if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
|
||
)
|
||
try:
|
||
du = shutil.disk_usage(str(base) if base.exists() else '.')
|
||
disk_free, disk_total = du.free, du.total
|
||
except Exception:
|
||
disk_free = disk_total = None
|
||
data = json.dumps({'used': used, 'disk_free': disk_free, 'disk_total': disk_total})
|
||
self._send(200, data.encode(), 'application/json')
|
||
|
||
def _api_config(self):
|
||
data = json.dumps({'threshold': self.threshold})
|
||
self._send(200, data.encode(), 'application/json')
|
||
|
||
def _api_delete(self, filename: str):
|
||
status_path = Path(self.recordings_dir) / 'status.json'
|
||
try:
|
||
with open(status_path) as fh:
|
||
if filename in set(json.load(fh).get('active', [])):
|
||
self._json_err(409, 'Cannot delete a file that is currently being recorded')
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
path = self._safe_path(filename)
|
||
if path is None:
|
||
return
|
||
|
||
try:
|
||
path.unlink()
|
||
except Exception as e:
|
||
self._json_err(500, f'Failed to delete: {e}')
|
||
return
|
||
|
||
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
||
|
||
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.is_file():
|
||
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:16px 28px;border-bottom:1px solid var(--brd);
|
||
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
header h1{font-size:18px;font-weight:600}
|
||
#subtitle{color:var(--muted);font-size:13px}
|
||
#storage-info{color:var(--muted);font-size:12px;margin-right:auto}
|
||
.controls-bar{display:flex;align-items:center;gap:10px;padding:10px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.controls-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.controls-bar input[type=number]{width:70px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.controls-bar input[type=number]:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
.controls-hint{font-size:11px;color:var(--muted)}
|
||
.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}
|
||
button.del{color:var(--red);border-color:#7f1d1d}
|
||
button.del:hover:not(:disabled){background:#2d0808}
|
||
/* 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}
|
||
button.chip{cursor:pointer}
|
||
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.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>
|
||
<span id="storage-info" aria-live="polite"></span>
|
||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||
</header>
|
||
<div class="controls-bar">
|
||
<label for="threshold-input">Analysis threshold:</label>
|
||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||
aria-describedby="threshold-hint">
|
||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
||
</div>
|
||
<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,'&').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}:${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();
|
||
// idx -> [{start,end}], populated after analysis
|
||
const sectionMap = new Map();
|
||
let activePlayerIdx = null;
|
||
|
||
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.preload = 'metadata';
|
||
audio.src = '/stream/' + encodeURIComponent(filename);
|
||
audio.load();
|
||
audio.setAttribute('data-src-set','1');
|
||
}
|
||
activePlayerIdx = idx;
|
||
prow.hidden = false;
|
||
btn.setAttribute('aria-expanded','true');
|
||
btn.textContent = '⏹ Hide';
|
||
btn.setAttribute('aria-label','Hide player for '+filename);
|
||
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;
|
||
}
|
||
|
||
function seekToSection(idx, filename, startSec) {
|
||
const pbtn = document.getElementById('pbtn-'+idx);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||
activePlayerIdx = idx;
|
||
const audio = document.getElementById('aud-'+idx);
|
||
const doSeek = () => { audio.currentTime = startSec; };
|
||
if (audio.readyState >= 1) doSeek();
|
||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||
}
|
||
|
||
async function analyse(idx, filename, cell, btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||
try {
|
||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||
+'&threshold='+encodeURIComponent(threshold));
|
||
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 — click to jump, J/K to step');
|
||
if (d.sections && d.sections.length) {
|
||
sectionMap.set(idx, d.sections);
|
||
d.sections.forEach(s => {
|
||
const c = document.createElement('button');
|
||
c.className='chip'; c.setAttribute('role','listitem');
|
||
c.title = 'Jump to this section (or use J/K keys)';
|
||
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
||
c.addEventListener('click', () => seekToSection(idx, filename, s.start));
|
||
chips.appendChild(c);
|
||
});
|
||
} else {
|
||
sectionMap.delete(idx);
|
||
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';
|
||
}
|
||
}
|
||
|
||
// J = previous section, K = next section (only when focus is not in an input)
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
if (activePlayerIdx === null) return;
|
||
const sections = sectionMap.get(activePlayerIdx) || [];
|
||
if (!sections.length) return;
|
||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||
if (!audio) return;
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
const cur = audio.currentTime;
|
||
let target = sections[0].start;
|
||
for (let i = sections.length - 1; i >= 0; i--) {
|
||
if (sections[i].start < cur - 1) { target = sections[i].start; break; }
|
||
}
|
||
audio.currentTime = target;
|
||
e.preventDefault();
|
||
} else if (e.key === 'k' || e.key === 'K') {
|
||
const cur = audio.currentTime;
|
||
for (const s of sections) {
|
||
if (s.start > cur + 0.5) { audio.currentTime = s.start; break; }
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
async function deleteFile(idx, filename) {
|
||
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
||
const btn = document.getElementById('delbtn-'+idx);
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
try {
|
||
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
||
if (r.ok) {
|
||
document.getElementById('row-'+idx)?.remove();
|
||
document.getElementById('prow-'+idx)?.remove();
|
||
recMap.delete(idx);
|
||
const remaining = document.querySelectorAll('tr.data-row').length;
|
||
document.getElementById('subtitle').textContent =
|
||
`${remaining} recording${remaining!==1?'s':''} found`;
|
||
if (!remaining) document.getElementById('empty').style.display = '';
|
||
updateStorage();
|
||
} else {
|
||
const d = await r.json().catch(()=>({}));
|
||
alert('Delete failed: '+(d.error||r.statusText));
|
||
btn.disabled = false; btn.textContent = '✕ Delete';
|
||
}
|
||
} catch(e) {
|
||
alert('Delete failed: '+e.message);
|
||
btn.disabled = false; btn.textContent = '✕ Delete';
|
||
}
|
||
}
|
||
|
||
async function updateStorage() {
|
||
try {
|
||
const s = await (await fetch('/api/storage')).json();
|
||
const el = document.getElementById('storage-info');
|
||
let txt = fmtSize(s.used) + ' used';
|
||
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
|
||
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
|
||
el.textContent = txt;
|
||
} catch(e) {}
|
||
}
|
||
|
||
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' : '';
|
||
updateStorage();
|
||
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>
|
||
<button id="delbtn-${i}" class="del"
|
||
aria-label="Delete ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||
</div>
|
||
</td>`;
|
||
tbody.appendChild(tr);
|
||
|
||
// ---- player row (hidden by default) ----
|
||
const prow = document.createElement('tr');
|
||
prow.className = 'player-row';
|
||
prow.id = 'prow-'+i;
|
||
prow.hidden = true;
|
||
const durLabel = f.duration != null
|
||
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
||
: '';
|
||
prow.innerHTML = `<td colspan="6">
|
||
<audio id="aud-${i}" controls preload="none"
|
||
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||
</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}`);
|
||
if (isRec) {
|
||
abtn.disabled = true;
|
||
abtn.title = 'Recording in progress — analyse after recording stops';
|
||
} else {
|
||
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
|
||
}
|
||
cell.appendChild(abtn);
|
||
}
|
||
|
||
// ---- attach play button handler ----
|
||
document.getElementById('pbtn-'+i)
|
||
.addEventListener('click', () => togglePlayer(i, f.name));
|
||
|
||
// ---- attach delete button handler ----
|
||
if (!isRec) {
|
||
document.getElementById('delbtn-'+i)
|
||
.addEventListener('click', () => deleteFile(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);
|
||
|
||
// Seed threshold input from server config, then start
|
||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||
if (cfg.threshold != null)
|
||
document.getElementById('threshold-input').value = cfg.threshold;
|
||
}).catch(() => {}).finally(() => 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 0–1 (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()
|