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:
2026-07-29 20:46:14 +02:00
co-authored by Claude Opus 5
parent a836948882
commit 625ee0e7db
2 changed files with 53 additions and 46 deletions
+49 -42
View File
@@ -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}")