Files
ISR/web.py
T
admin 8e496ec2c4 perf: faster page loads, live-recording playback and seeking fixes
Server (web.py):
- /api/analyze no longer returns the full per-window RMS array (~45x
  larger than the rms_display the UI actually renders); old caches are
  stripped on read
- /api/files reads only the first 256 bytes of each analysis cache to
  get threshold/min_gap instead of parsing the whole JSON
- durations cached by (mtime, size) instead of re-opening every audio
  header per request; stat() race with deleted files guarded
- /api/storage no longer walks the recordings tree (used bytes now
  computed client-side from the file list)
- HTTP/1.1 keep-alive enabled; short writes force-close the connection;
  client-disconnect tracebacks from aborted seeks silenced
- all file copies bounded by the advertised Content-Length so files
  growing during a response cannot desync the connection

Live recording playback:
- /stream/ patches in-progress WAV headers to the current file size so
  browsers show real duration and can seek (on-disk header says 0
  frames until the recorder closes the file)
- active files served with Cache-Control: no-store
- reopening the player for a recording file reloads the source to pick
  up newly captured audio

UI loading:
- analyses lazy-load only for expanded day groups; collapsed days defer
  fetching until opened, and auto-load only when cached parameters
  match the current controls (no surprise mass recompute)
- client-side analysis cache shared by file rows and day highlights, so
  re-renders and filters never refetch
- filename filter debounced (200 ms)
- file list auto-refreshes when the active recording set changes,
  unless audio is playing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:29:13 +02:00

797 lines
29 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 shutil
import struct
import subprocess
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 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 _live_wav_header(path: Path, size: int):
"""Return the WAV header (through the 'data' chunk header) with RIFF and
data sizes rewritten to match the current file size, or None.
While a WAV file is still being recorded its header claims ~0 frames, so
browsers show no duration and refuse to seek. Serving a header patched to
the bytes recorded so far fixes both; the patch is the same length as the
original header, so all byte offsets and Range math stay valid.
"""
try:
with open(path, 'rb') as fh:
hdr = fh.read(512)
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
return None
pos = 12
while pos + 8 <= len(hdr):
chunk_id = hdr[pos:pos + 4]
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
if chunk_id == b'data':
data_off = pos + 8
patched = bytearray(hdr[:data_off])
patched[4:8] = (size - 8).to_bytes(4, 'little')
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
return bytes(patched)
pos += 8 + chunk_size + (chunk_size & 1)
return None
except Exception:
return 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':
try:
with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate()
except Exception:
return None
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."""
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)
def _loud_sections(rms_values: list, window_dur: float, duration: float,
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> 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:
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,
min_gap: float = MIN_GAP_SECONDS) -> 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
# Note: the full per-window RMS list is deliberately NOT returned — the UI
# only renders rms_display (~800 points), and the full list is ~45x larger.
return {
'rms_display': rms_display,
'sections': _loud_sections(rms_values, window_dur, duration, threshold, min_gap),
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD,
min_gap: float = MIN_GAP_SECONDS) -> 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, min_gap)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD,
min_gap: float = MIN_GAP_SECONDS) -> 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, min_gap)
# ---------------------------------------------------------------------------
# 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):
"""Read just threshold/min_gap from a cache file without parsing the whole
JSON (the embedded result can be hundreds of KB). Relies on the writer in
_api_analyze putting these two keys first."""
try:
with open(cache_path, 'r', encoding='utf-8') as fh:
head = fh.read(256)
except OSError:
return None
m = re.search(r'"threshold":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head)
if not m:
return None
return {'threshold': float(m.group(1)), 'min_gap': float(m.group(2))}
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
# 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': 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):
# 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'
threshold: float = LOUD_THRESHOLD
min_gap: float = MIN_GAP_SECONDS
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 == '/api/cut':
self._api_cut(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 _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
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
try:
threshold = float(qs.get('threshold', [self.threshold])[0])
threshold = max(0.0, min(1.0, threshold))
except (ValueError, TypeError):
threshold = self.threshold
try:
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
min_gap = max(0.0, min(300.0, min_gap))
except (ValueError, TypeError):
min_gap = self.min_gap
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('threshold') == threshold and cached.get('min_gap') == min_gap:
payload = dict(cached['result'])
payload.pop('rms', None) # caches written before the full-RMS field was dropped
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, threshold=threshold, min_gap=min_gap)
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, min_gap=min_gap)
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({'threshold': threshold, 'min_gap': min_gap, '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 _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:
self._copy_to_response(fh, size)
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, with a header patched to the
current size so the browser can show a duration 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 and path.suffix.lower() == '.wav':
prefix = _live_wav_header(path, size) or b''
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')
if is_active:
self.send_header('Cache-Control', 'no-store')
self.end_headers()
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)
self._copy_to_response(fh, length - sent)
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')
if is_active:
self.send_header('Cache-Control', 'no-store')
self.end_headers()
with open(path, 'rb') as fh:
if prefix:
self.wfile.write(prefix)
fh.seek(len(prefix))
self._copy_to_response(fh, size - len(prefix))
else:
# 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, size)
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({'threshold': self.threshold, 'min_gap': self.min_gap})
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
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 = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
# 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 _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()
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):
import sys
exc = sys.exc_info()[1]
if isinstance(exc, (ConnectionError, TimeoutError)):
return
super().handle_error(request, client_address)
# ---------------------------------------------------------------------------
# UI page — single-page HTML/CSS/JS, loaded once at startup
# ---------------------------------------------------------------------------
_HTML = (Path(__file__).resolve().parent / 'webui.html').read_text(encoding='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('--threshold', type=float, default=LOUD_THRESHOLD,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
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('--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.")
prune_orphan_analyses(analyses_dir, rec_dir)
_analyses_dir = analyses_dir # class body can't close over a name it also assigns
class Handler(_Handler):
recordings_dir = str(rec_dir)
analyses_dir = str(_analyses_dir)
threshold = args.threshold
min_gap = args.min_gap
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"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()