refactor: simplify web.py — one param parser, plain cache reads, no generated handler class
No behaviour change beyond dropping an unread response field. * _api_analyze had three identical parse-and-clamp blocks differing only in name/default/bounds. One _qs_float() helper; adding a fourth param is now a line rather than a copy of the block. * _cached_analysis_params regexed four keys out of the first 256 bytes of each cache file to avoid parsing "the embedded result (hundreds of KB)", which forced those keys to be written first — a constraint documented in three places and silently fatal if ever violated. The RMS arrays that justified it were dropped when the waveforms went; a cache is now a few floats and a section list, so json.load is both simpler and immune to key order. * /api/analyze no longer returns `window`. It existed to place RMS bars and has had no consumer since the waveform SVGs were removed. * main() generated an anonymous _Handler subclass just to inject five attributes, needing an alias to work around class-body scoping. Set them on _Handler directly. * `import sys` moved out of _Server.handle_error to the top of the file. Verified with the CLAUDE.md endpoint smoke pattern: /api/analyze section bounds, cache round-trip through the rewritten reader, cached_analysis surfacing in /api/files, and /api/clip Content-Length == len(body) == 44 + frames*channels*2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
@@ -409,11 +410,11 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
|
||||
duration = n_frames / framerate
|
||||
|
||||
# Note: no RMS data is returned — the UI is screen-reader oriented and
|
||||
# draws no waveform, so sections + duration is all it needs.
|
||||
# draws no waveform, so sections + duration is all it needs. The window
|
||||
# length went the same way: it only ever existed to place RMS bars.
|
||||
return {
|
||||
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
|
||||
'duration': round(duration, 2),
|
||||
'window': round(window_dur, 4),
|
||||
}
|
||||
|
||||
|
||||
@@ -480,22 +481,31 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
|
||||
|
||||
|
||||
def _cached_analysis_params(cache_path: Path):
|
||||
"""Read just detector/margin/min_gap/min_duration 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 keys first.
|
||||
Caches written by other detector versions (or so old they lack a key)
|
||||
simply never match and get recomputed on the next analyse."""
|
||||
"""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:
|
||||
head = fh.read(256)
|
||||
except OSError:
|
||||
cached = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
m = re.search(r'"detector":\s*(\d+),\s*"margin":\s*([0-9.eE+-]+),'
|
||||
r'\s*"min_gap":\s*([0-9.eE+-]+),\s*"min_duration":\s*([0-9.eE+-]+)', head)
|
||||
if not m or int(m.group(1)) != DETECTOR_VERSION:
|
||||
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
|
||||
return {'margin': float(m.group(2)), 'min_gap': float(m.group(3)),
|
||||
'min_duration': float(m.group(4))}
|
||||
|
||||
|
||||
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
||||
@@ -598,6 +608,19 @@ def list_files(recordings_dir: str):
|
||||
# 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.
|
||||
@@ -674,23 +697,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
if path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
margin = float(qs.get('margin', [self.margin_db])[0])
|
||||
margin = max(1.0, min(60.0, margin))
|
||||
except (ValueError, TypeError):
|
||||
margin = self.margin_db
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
min_duration = float(qs.get('min_duration', [self.min_duration])[0])
|
||||
min_duration = max(0.0, min(60.0, min_duration))
|
||||
except (ValueError, TypeError):
|
||||
min_duration = self.min_duration
|
||||
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')
|
||||
@@ -729,8 +738,6 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cache_path.with_suffix('.tmp')
|
||||
# detector, margin, min_gap and min_duration MUST stay first:
|
||||
# _cached_analysis_params reads only the first 256 bytes of this file
|
||||
tmp.write_text(json.dumps({'detector': DETECTOR_VERSION,
|
||||
'margin': margin, 'min_gap': min_gap,
|
||||
'min_duration': min_duration, 'result': result}), 'utf-8')
|
||||
@@ -1126,7 +1133,6 @@ class _Server(ThreadingHTTPServer):
|
||||
(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
|
||||
@@ -1172,15 +1178,16 @@ def main():
|
||||
|
||||
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)
|
||||
margin_db = args.margin
|
||||
min_gap = args.min_gap
|
||||
min_duration = args.min_duration
|
||||
# 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)
|
||||
server = _Server((args.host, args.port), _Handler)
|
||||
|
||||
print(f"ISR Web running on http://{args.host}:{args.port}/")
|
||||
print(f"Recordings dir: {rec_dir}")
|
||||
|
||||
Reference in New Issue
Block a user