Files
ISR/web.py
T
adminandClaude Opus 5.5 2a19515c27 feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:05:15 +02:00

830 lines
33 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 sections that stand out above the
background noise.
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 --margin 15 # dB above noise floor (default 12)
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
try:
import numpy # noqa: F401 — only the availability flag is used here,
NUMPY_AVAILABLE = True # to refuse FLAC work rather than fail halfway
except ImportError:
NUMPY_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
# The detector and the byte-level format helpers live beside this file. Both
# are leaves: they import nothing from here, so `web.py -> {loudness,
# audiofile}` is the only import edge and there are no cycles.
from isrweb.audiofile import (AUDIO_EXTENSIONS, MIME_TYPES, _cut_filename,
_get_audio_duration, _live_flac_header,
_live_wav_header, _recording_start, _wav_header)
from isrweb.loudness import (DETECTOR_VERSION, MARGIN_DB, MIN_DURATION_SECONDS,
MIN_GAP_SECONDS, analyze_flac, analyze_wav)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
# ---------------------------------------------------------------------------
# Analysis cache helpers
# ---------------------------------------------------------------------------
def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path: Path) -> Path:
rel = audio_path.relative_to(recordings_base)
return analyses_base / rel.parent / (rel.name + '.analysis.json')
def _cached_analysis_params(cache_path: Path):
"""The params a cache file was written with, or None if it is unusable.
Caches from another DETECTOR_VERSION — or so old they lack a key — return
None and are recomputed on the next analyse.
This used to regex four keys out of the first 256 bytes to avoid parsing
the whole file, which imposed a "these keys must be written first"
constraint on the writer. That was worth it when the cache embedded an
~800-point RMS preview and the full per-window list; those were dropped
when the waveforms went, so a cache is now a handful of floats and a
section list, and a plain load is both simpler and unbreakable by key
order."""
try:
with open(cache_path, 'r', encoding='utf-8') as fh:
cached = json.load(fh)
except (OSError, ValueError):
return None
if cached.get('detector') != DETECTOR_VERSION:
return None
try:
return {'margin': float(cached['margin']),
'min_gap': float(cached['min_gap']),
'min_duration': float(cached['min_duration'])}
except (KeyError, TypeError, ValueError):
return None
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
if not analyses_base.exists():
return
removed = 0
for cache in analyses_base.rglob('*.analysis.json'):
rel = cache.relative_to(analyses_base)
audio_path = recordings_base / rel.parent / rel.name[:-len('.analysis.json')]
if not audio_path.exists():
try:
cache.unlink()
removed += 1
except Exception:
pass
if removed:
print(f'Pruned {removed} orphaned analysis cache file(s)')
# ---------------------------------------------------------------------------
# File listing
# ---------------------------------------------------------------------------
# rel-path -> ((mtime_ns, size), duration); avoids re-opening every audio
# header on each /api/files request
_DURATION_CACHE: dict = {}
_DURATION_CACHE_LOCK = threading.Lock()
def _cached_duration(path: Path, rel: str, stat) -> float:
sig = (stat.st_mtime_ns, stat.st_size)
with _DURATION_CACHE_LOCK:
hit = _DURATION_CACHE.get(rel)
if hit is not None and hit[0] == sig:
return hit[1]
duration = _get_audio_duration(path)
with _DURATION_CACHE_LOCK:
_DURATION_CACHE[rel] = (sig, duration)
return duration
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
try:
stat = path.stat()
except OSError:
continue # deleted between rglob and stat
rel = str(path.relative_to(base)).replace('\\', '/')
is_active = rel in active_files
# The recording start is encoded in the filename and is the true clock
# anchor; mtime is only a fallback for files not in FILENAME_FORMAT.
started = _recording_start(path.stem) or datetime.fromtimestamp(stat.st_mtime)
date = started.strftime('%Y-%m-%d %H:%M:%S')
# 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 _cached_duration(path, rel, stat)
files.append({
'name': rel,
'size': stat.st_size,
'mtime': stat.st_mtime,
'date': date,
'duration': duration,
'ext': path.suffix.lower().lstrip('.'),
'recording': is_active,
})
# Sort by the recording start, not mtime: the filename is the clock, and
# everything the user sees — the DATE column, the day grouping, the date
# filters — is derived from it. mtime is the last write (≈ the end), so a
# file whose mtime drifted from its name (a copy, a restore, an ffmpeg
# round-trip) used to land at the wrong position within its own day.
# 'date' is fixed-width zero-padded, so string order is time order.
files.sort(key=lambda f: f['date'], reverse=True)
return files
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
def _qs_float(qs, name: str, default: float, lo: float, hi: float) -> float:
"""A query-string float clamped to [lo, hi], or default if unparseable.
Analysis params are coupled across the CLI, /api/config, the UI inputs and
the cache keys — keeping the parse in one place means adding a fourth is a
one-line change here rather than another copy of this block.
"""
try:
return max(lo, min(hi, float(qs.get(name, [default])[0])))
except (ValueError, TypeError):
return default
class _Handler(BaseHTTPRequestHandler):
# Keep-alive: browsers reuse connections instead of a TCP handshake per
# request. Safe because every response sets Content-Length.
protocol_version = 'HTTP/1.1'
recordings_dir: str = 'recordings'
analyses_dir: str = 'recordings/analyses'
margin_db: float = MARGIN_DB
min_gap: float = MIN_GAP_SECONDS
min_duration: float = MIN_DURATION_SECONDS
# True once /api/clip has written its audio headers — see _api_clip
_clip_started: bool = False
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_HEAD(self):
"""HEAD for the page and the audio files — some players probe before
fetching. Clips and cuts are generated on demand, so answering HEAD
for those would do all the work and throw the result away."""
p = urlparse(self.path).path
if p == '/' or p.startswith('/download/') or p.startswith('/stream/'):
self.do_GET()
else:
self._send(405, b'Method Not Allowed', 'text/plain')
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
p = parsed.path
if p == '/':
self._ui('index.html')
elif p.startswith('/ui/'):
self._ui(unquote(p[len('/ui/'):]))
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 == '/api/cut':
self._api_cut(qs)
elif p == '/api/clip':
self._api_clip(qs)
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 _ui(self, name: str):
"""Serve a file of the browser UI from webui/. Read per request: it is
a handful of small files, and edits show up on reload without a server
restart. Only the listed types are served, and the path must stay
inside webui/ (same guard as _safe_path, but for the UI folder)."""
try:
path = (WEBUI_DIR / name).resolve()
path.relative_to(WEBUI_DIR)
except ValueError:
path = None
ctype = _UI_TYPES.get(path.suffix) if path else None
if ctype is None or not path.is_file():
self._send(404, b'Not found', 'text/plain')
return
self._send(200, path.read_bytes(), ctype)
def _api_files(self):
files = list_files(self.recordings_dir)
recordings_base = Path(self.recordings_dir).resolve()
analyses_base = Path(self.analyses_dir).resolve()
for f in files:
if f.get('ext') in ('wav', 'flac') and not f.get('recording'):
cache_path = _analysis_cache_path(
analyses_base, recordings_base, recordings_base / f['name'])
f['cached_analysis'] = _cached_analysis_params(cache_path)
else:
f['cached_analysis'] = None
self._send(200, json.dumps(files).encode('utf-8'), '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
margin = _qs_float(qs, 'margin', self.margin_db, 1.0, 60.0)
min_gap = _qs_float(qs, 'min_gap', self.min_gap, 0.0, 300.0)
min_duration = _qs_float(qs, 'min_duration', self.min_duration, 0.0, 60.0)
if self._is_active(filename):
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
return
recordings_base = Path(self.recordings_dir).resolve()
analyses_base = Path(self.analyses_dir).resolve()
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
try:
cached = json.loads(cache_path.read_text('utf-8'))
if (cached.get('detector') == DETECTOR_VERSION
and cached.get('margin') == margin and cached.get('min_gap') == min_gap
and cached.get('min_duration') == min_duration):
payload = dict(cached['result'])
# strip fields that older cache versions embedded
payload.pop('rms', None) # full per-window RMS list
payload.pop('rms_display', None) # ~800-point waveform preview
payload['cached'] = True
self._send(200, json.dumps(payload).encode('utf-8'), 'application/json')
return
except Exception:
pass
ext = path.suffix.lower()
if ext == '.wav':
result = analyze_wav(path, margin_db=margin, min_gap=min_gap, min_duration=min_duration)
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, margin_db=margin, min_gap=min_gap, min_duration=min_duration)
else:
self._json_err(400, f'Loudness analysis is not available for {ext} files')
return
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = cache_path.with_suffix('.tmp')
tmp.write_text(json.dumps({'detector': DETECTOR_VERSION,
'margin': margin, 'min_gap': min_gap,
'min_duration': min_duration, 'result': result}), 'utf-8')
os.replace(tmp, cache_path)
except Exception as e:
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
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 _serve_bytes(self, path: Path, size: int, content_type: str,
prefix: bytes = b'', no_store: bool = False,
disposition: str = None):
"""Serve a file with HTTP Range support.
`prefix` replaces the first len(prefix) bytes of the file on the way
out — that is how in-progress recordings get a header patched to the
duration captured so far. It is the same length as the header it
replaces, so all offsets and Range math stay valid.
"""
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-Range', f'bytes {start}-{end}/{size}')
else:
start, length = 0, size
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(length))
self.send_header('Accept-Ranges', 'bytes')
if disposition:
self.send_header('Content-Disposition', disposition)
if no_store:
self.send_header('Cache-Control', 'no-store')
self.end_headers()
if self.command == 'HEAD':
return
with open(path, 'rb') as fh:
sent = 0
if start < len(prefix):
head = prefix[start:start + length]
self.wfile.write(head)
sent = len(head)
if sent < length:
fh.seek(start + sent)
# Bound the copy: the file may grow while we serve it, and
# writing more than Content-Length desyncs keep-alive.
self._copy_to_response(fh, length - sent)
def _download(self, filename: str):
path = self._safe_path(filename)
if path is None:
return
# Range support matters here too: these files run to hundreds of MB
# and an interrupted download would otherwise have to restart.
self._serve_bytes(path, path.stat().st_size, 'application/octet-stream',
disposition=f'attachment; filename="{path.name}"')
def _stream(self, filename: str):
"""Serve audio for inline playback with HTTP Range support.
In-progress recordings are served with Cache-Control: no-store (the
content is still growing) and, for WAV/FLAC, with a header patched to
the duration recorded so far so the browser can show it and seek.
"""
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
is_active = self._is_active(filename)
prefix = b''
if is_active:
ext = path.suffix.lower()
if ext == '.wav':
prefix = _live_wav_header(path, size) or b''
elif ext == '.flac':
prefix = _live_flac_header(path, size) or b''
self._serve_bytes(path, size, content_type, prefix=prefix, no_store=is_active)
def _api_storage(self):
# 'used' is computed client-side from the file list; walking the whole
# tree again here doubled the I/O of every page load.
base = Path(self.recordings_dir)
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({'disk_free': disk_free, 'disk_total': disk_total})
self._send(200, data.encode(), 'application/json')
def _api_config(self):
data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap,
'min_duration': self.min_duration})
self._send(200, data.encode(), 'application/json')
def _api_delete(self, filename: str):
if self._is_active(filename):
self._json_err(409, 'Cannot delete a file that is currently being recorded')
return
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
try:
_analysis_cache_path(
Path(self.analyses_dir).resolve(),
Path(self.recordings_dir).resolve(),
path,
).unlink()
except Exception:
pass
with _DURATION_CACHE_LOCK:
_DURATION_CACHE.pop(filename, None)
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
def _api_cut(self, qs):
filename = qs.get('file', [None])[0]
start_s = qs.get('start', [None])[0]
end_s = qs.get('end', [None])[0]
if not filename or start_s is None or end_s is None:
self._json_err(400, 'missing file, start, or end parameter')
return
try:
start = float(start_s)
end = float(end_s)
except (ValueError, TypeError):
self._json_err(400, 'start and end must be numbers (seconds)')
return
if start < 0 or end <= start:
self._json_err(400, 'start must be ≥ 0 and end must be > start')
return
path = self._safe_path(filename)
if path is None:
return
if self._is_active(filename):
self._json_err(409, 'Cannot cut a file that is currently being recorded')
return
if not shutil.which('ffmpeg'):
self._json_err(500, 'ffmpeg is not available on this server')
return
ext = path.suffix.lower()
out_name = _cut_filename(path.stem, ext, start, end)
# For lossless formats, re-encode (not copy) so the container header
# is rewritten with the correct duration/size. For lossy formats,
# copy is fine — the audio stops at the right frame regardless.
_lossless = {'.wav': ['-c:a', 'pcm_s16le'], '.flac': ['-c:a', 'flac']}
codec_args = _lossless.get(ext, ['-c', 'copy'])
fd, tmp_path = tempfile.mkstemp(suffix=ext)
os.close(fd)
try:
cmd = ['ffmpeg', '-y',
'-i', str(path),
'-ss', str(start), '-to', str(end),
'-vn'] + codec_args + [tmp_path]
result = subprocess.run(cmd, capture_output=True, timeout=120)
if result.returncode != 0:
err = result.stderr.decode('utf-8', errors='replace')[-400:]
self._json_err(500, f'ffmpeg error: {err}')
return
tmp_size = os.path.getsize(tmp_path)
content_type = MIME_TYPES.get(ext, 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Disposition', f'attachment; filename="{out_name}"')
self.send_header('Content-Length', str(tmp_size))
self.end_headers()
with open(tmp_path, 'rb') as fh:
self._copy_to_response(fh, tmp_size)
except subprocess.TimeoutExpired:
self._json_err(504, 'ffmpeg timed out — file may be too large')
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
def _api_clip(self, qs):
"""Serve a section of a WAV/FLAC file as a small standalone WAV.
Decoding the slice server-side means the browser plays a tiny file
instantly instead of seeking inside a multi-hundred-MB recording
(FLACs have no seek table, so browser seeks bisect the whole file
with Range requests)."""
filename = qs.get('file', [None])[0]
start_s = qs.get('start', [None])[0]
end_s = qs.get('end', [None])[0]
if not filename or start_s is None or end_s is None:
self._json_err(400, 'missing file, start, or end parameter')
return
try:
start = max(0.0, float(start_s))
end = float(end_s)
except (ValueError, TypeError):
self._json_err(400, 'start and end must be numbers (seconds)')
return
if end <= start:
self._json_err(400, 'end must be > start')
return
end = min(end, start + CLIP_MAX_SECONDS)
path = self._safe_path(filename)
if path is None:
return
if self._is_active(filename):
self._json_err(409, 'File is currently being recorded — clips unavailable until recording stops')
return
ext = path.suffix.lower()
self._clip_started = False
try:
if ext == '.wav':
self._clip_wav(path, start, end)
elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC clips require: pip install numpy soundfile')
return
self._clip_flac(path, start, end)
else:
self._json_err(400, f'Clips are not available for {ext} files')
except (ConnectionError, BrokenPipeError):
raise
except Exception as e:
# Once the audio headers are out, a JSON error body would be a
# second response written into a stream that already promised
# audio/wav and a Content-Length — with keep-alive that desyncs
# the connection for every later request on it. Drop it instead.
if self._clip_started:
print(f'Clip failed mid-stream: {e}', flush=True)
self.close_connection = True
else:
self._json_err(500, f'Clip failed: {e}')
def _send_clip_headers(self, header: bytes, n_frames: int, channels: int,
sampwidth: int):
self._clip_started = True
self.send_response(200)
self.send_header('Content-Type', 'audio/wav')
self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth))
# Finished recordings are immutable, so stepping back to a section
# replays the clip from the browser cache
self.send_header('Cache-Control', 'private, max-age=86400')
self.end_headers()
self.wfile.write(header)
def _clip_wav(self, path: Path, start: float, end: float):
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
f0 = min(int(start * framerate), n_frames)
f1 = min(int(end * framerate), n_frames)
if f1 <= f0:
self._json_err(400, 'clip range is beyond the end of the file')
return
header = _wav_header(f1 - f0, channels, framerate, sampwidth)
self._send_clip_headers(header, f1 - f0, channels, sampwidth)
wf.setpos(f0)
remaining = f1 - f0
while remaining > 0:
chunk = wf.readframes(min(32768, remaining))
if not chunk:
self.close_connection = True # under-delivered
break
self.wfile.write(chunk)
remaining -= len(chunk) // (channels * sampwidth)
def _clip_flac(self, path: Path, start: float, end: float):
with sf.SoundFile(path) as f:
framerate = f.samplerate
channels = f.channels
n_frames = len(f)
f0 = min(int(start * framerate), n_frames)
f1 = min(int(end * framerate), n_frames)
if f1 <= f0:
self._json_err(400, 'clip range is beyond the end of the file')
return
header = _wav_header(f1 - f0, channels, framerate, 2)
self._send_clip_headers(header, f1 - f0, channels, 2)
f.seek(f0)
remaining = f1 - f0
while remaining > 0:
frames = f.read(min(32768, remaining), dtype='int16', always_2d=True)
if len(frames) == 0:
self.close_connection = True # under-delivered
break
self.wfile.write(frames.tobytes())
remaining -= len(frames)
def _is_active(self, filename: str) -> bool:
"""True if isr.py reports this file as currently being recorded."""
try:
with open(Path(self.recordings_dir) / 'status.json') as fh:
return filename in json.load(fh).get('active', [])
except Exception:
return False
def _copy_to_response(self, fh, length=None):
"""Stream an open binary file to the client in 64 KB chunks."""
remaining = length
while remaining is None or remaining > 0:
chunk = fh.read(65536 if remaining is None else min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
if remaining is not None:
remaining -= len(chunk)
# Sent fewer bytes than Content-Length promised (file truncated while
# serving): the keep-alive connection is desynced, force it closed.
if remaining is not None and remaining > 0:
self.close_connection = True
def _safe_path(self, filename: str):
base = Path(self.recordings_dir).resolve()
try:
path = (base / filename).resolve()
path.relative_to(base)
except 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()
if self.command != 'HEAD':
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
class _Server(ThreadingHTTPServer):
"""ThreadingHTTPServer that stays quiet when clients disconnect mid-stream
(browsers abort audio range requests constantly while seeking)."""
def handle_error(self, request, client_address):
exc = sys.exc_info()[1]
if isinstance(exc, (ConnectionError, TimeoutError)):
return
super().handle_error(request, client_address)
# ---------------------------------------------------------------------------
# Browser UI — static files in webui/ (index.html, style.css, js/*.js modules)
# ---------------------------------------------------------------------------
WEBUI_DIR = Path(__file__).resolve().parent / 'webui'
_UI_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
}
# ---------------------------------------------------------------------------
# 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('--margin', type=float, default=MARGIN_DB,
help=f'dB above the background noise floor for a section '
f'to count as loud (default: {MARGIN_DB})')
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
parser.add_argument('--min-duration', type=float, default=MIN_DURATION_SECONDS,
help=f'Discard loud sections shorter than this many seconds '
f'(default: {MIN_DURATION_SECONDS})')
parser.add_argument('--analyses-dir', default=None,
help='Directory for analysis cache files (default: <recordings-dir>/analyses)')
args = parser.parse_args()
rec_dir = Path(args.dir).resolve()
analyses_dir = Path(args.analyses_dir).resolve() if args.analyses_dir else rec_dir / 'analyses'
if not rec_dir.exists():
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
if not (WEBUI_DIR / 'index.html').is_file():
sys.exit(f"Error: browser UI not found at {WEBUI_DIR} — keep webui/ next to web.py.")
prune_orphan_analyses(analyses_dir, rec_dir)
# Config lives on the handler class (one server per process). Setting the
# attributes directly beats generating a subclass here, which also needed
# an alias to dodge Python's class-body scoping.
_Handler.recordings_dir = str(rec_dir)
_Handler.analyses_dir = str(analyses_dir)
_Handler.margin_db = args.margin
_Handler.min_gap = args.min_gap
_Handler.min_duration = args.min_duration
server = _Server((args.host, args.port), _Handler)
print(f"ISR Web running on http://{args.host}:{args.port}/")
print(f"Recordings dir: {rec_dir}")
print(f"Analyses dir: {analyses_dir}")
print(f"Loudness margin: {args.margin} dB above noise floor")
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()