Pure move, no behaviour change. web.py 1221 -> 807 lines.
The reason is not file size but isolation of change. CLAUDE.md records
that the next step for detection, should flagging ever need improving, is
a spectral filter or an optional Silero VAD over candidate sections —
that work now lands entirely in isrweb/loudness.py and never has to open
the HTTP layer. The byte-level format code (FLAC frame parsing, CRC-8,
live header patching) is a second self-contained cluster, touched only
when a container format misbehaves.
isrweb/loudness.py 229 detector + its tuning constants
isrweb/audiofile.py 250 filename clock, WAV/FLAC headers, MIME types
web.py 807 cache, listing, handler, main()
Import direction is strictly web.py -> {loudness, audiofile}; the two
leaves import neither each other nor web.py, which is what lets the
detector be imported and tested without an HTTP server.
A package rather than flat modules because `audiofile` is a real PyPI
name and `detect` is generic enough to collide; a local file silently
shadows an installed one, which fails confusingly rather than loudly.
web.py stays at the repo root so `python web.py`, the docker-compose
command and the webui.html lookup are all unchanged.
Each module keeps its own optional numpy/soundfile try-import rather than
sharing one. That block cannot meaningfully diverge — if numpy is
installed every copy says True — and sharing it would stop isr.py being a
standalone file you can drop on a Pi and run.
Tests mirror the modules: test_loudness.py, test_audiofile.py, test_web.py
(+ unchanged test_isr.py). New tests/conftest.py owns the sys.path setup
that was previously an inline hack in test_isr.py — which only worked for
the other modules because that file happened to import first — and
provides the write_wav fixture the split would otherwise have duplicated.
Verified:
* 78 passed, same count as before the split; each test file also passes
standalone.
* All 21 moved functions diffed against HEAD:web.py — byte-identical.
* Import graph asserted acyclic; both leaves import with `web` absent
from sys.modules.
* An analysis cache written *before* the split still hits afterwards,
with identical sections — the one way a "pure" move could quietly
invalidate every user's cache.
* Endpoint smoke (analyze bounds, cache hit, cached_analysis in the
listing, clip Content-Length, 8-bit rejection) and the byte-serving
harness (ranges, download Range, HEAD, 405, live-header patching whole
and straddling) both re-run green.
* Dockerfile COPY set verified by building the image's file tree in a
temp dir and importing web.py with nothing else present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
808 lines
32 KiB
Python
808 lines
32 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._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 == '/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 _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
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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('--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.")
|
|
|
|
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()
|