web.py
* _download and _stream each had their own copy loop; _download had no
Range support at all, so an interrupted download of a multi-hundred-MB
recording had to restart. Both now go through _serve_bytes(), which
keeps _stream's live-header `prefix` trick (the patched bytes are the
same length as the header they replace, so Range math is unaffected).
* do_HEAD answers / , /stream/ and /download/ as a body-less do_GET.
/api/clip and /api/cut return 405 instead — generating a clip or
shelling out to ffmpeg only to discard the result is worse than not
answering.
* _api_delete drops the deleted file's _DURATION_CACHE entry, which was
never evicted.
* WAV durations are rounded to 2dp like FLAC's already were.
isr.py
* _open_output_file flushed the buffer and then called
close_current_file(), which is overridden to flush again.
* log_level/log_file were merged into the dict copied into every
source's config; no recorder reads them. Read them where they are used.
webui.html
* scoreOrder() is memoised on the section array's identity — U/I
otherwise re-sorted a whole day (thousands of sections) per keypress.
Verified with a byte-level harness: full/partial/open-ended/unsatisfiable
ranges byte-identical to disk, download Range, HEAD on all three
supported paths returning headers with an empty body, 405 on /api/clip,
and live-header patching still correct both whole-file and for a range
straddling the patched/disk boundary.
Not done from the audit: BaseRecorder.generate_filename's mkdir per file
open. It runs once per split (once an hour by default), so "once at
startup suffices" was not worth a behaviour change. `nul` stays in
.gitignore — it guards against a Windows shell artifact on a Windows box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
list_files sorted on mtime while `date` — and so the DATE column, the day
grouping and both date filters — came from _recording_start(path.stem).
The two disagree for any file whose mtime drifted from its name: a copy,
a restore, an ffmpeg round-trip. Such a file landed in the right day
group but at the wrong position inside it, and could drag its whole day
group to the wrong place on the page.
'date' is fixed-width zero-padded, so sorting the string is already
chronological — no extra field needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_compute_rms_windows_wav honours sampwidth for frame arithmetic but
decodes as int16 unconditionally, so an 8- or 24-bit WAV produced
meaningless RMS values and therefore meaningless sections, silently.
The recorder only writes 16-bit, but --dir points at arbitrary
directories. analyze_wav now refuses anything else, turning a silent
wrong answer into a visible one.
_loud_sections closed mid-file sections at last_loud_t + window_dur but
the trailing one at the file duration. A file whose last loud event is
followed by less than min_gap of quiet reported a section stretched by
up to min_gap seconds, and the clip served for it carried that silence.
Now capped at the last loud window, consistent with every other section
and with the score, which only ever covered the loud windows.
DETECTOR_VERSION 2 -> 3 so existing caches are recomputed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/clip: _clip_wav/_clip_flac send audio headers and then stream frames.
A decode failure mid-stream unwound into _api_clip's handler, which called
_json_err(500) — writing a second status line and a JSON body into a
response that had already promised Content-Type: audio/wav and an exact
Content-Length. Under HTTP/1.1 keep-alive that desyncs the connection for
every subsequent request on it, so one bad clip corrupted unrelated
traffic. Track whether the headers went out; if they did, log and force
the connection closed instead, the same way _copy_to_response already
handles under-delivery.
config.ini: the int() coercion for split_minutes/max_retries/etc sat
outside the try that wraps recorder construction, so `split_minutes =
sixty` aborted the entire config load with an unhandled ValueError rather
than skipping that one section like every other config error. Moved
inside. split_duration is also clamped to >= 1 — 0 divided by zero in
get_next_split_time().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A section's score is now its peak dB above the noise floor capped by
the sharpest rise within ONSET_SECONDS (0.5 s). Real events (voices,
impacts, barks) rise fast and keep their full prominence; a gradual
swell that outruns the 30 s floor blocks (gusts, distant approaching
cars) still flags but scores near zero, so score-ranked review (chips,
U/I highlights, "Highlights only" mode) surfaces events first. A
section starting in a file's first 0.5 s is scored against the floor
instead, so events cut off by a file split are not punished as swells.
Old cached analyses carry now-wrong scores, so the cache gains a
leading "detector" version key (DETECTOR_VERSION = 2) checked by both
_cached_analysis_params() and the /api/analyze cache hit path; v1
caches never match and are recomputed on the next analyse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cut downloads were named by byte offsets (`..._cut_740s-750s.flac`). They are
now named by the actual recording time the slice covers, e.g.
`20260523_22-31-30_22-32-30.flac` for a 22:31:30->22:32:30 cut of a recording
started at 22:00:00.
To make this reliable, the recording filename is now a fixed
`%Y%m%d_%H%M%S` start-time format (`FILENAME_FORMAT`) shared by isr.py and
web.py, replacing the user-configurable `filename_pattern` (web.py never reads
config.ini, so a custom pattern could not be parsed back). web.py parses the
start time out of the filename via `_recording_start()` and builds cut names
with `_cut_filename()`. The DATE column now also comes from the filename
(falling back to mtime only for non-standard names), since mtime is the last
write, not the start.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single 100 ms RMS window above the noise floor used to become its own
section, so isolated pops (clicks, single raindrops) flooded a day with
thousands of sub-second clips like "21:18 to 21:18". Sections shorter
than min_duration (measured after min_gap merging, so a cluster of blips
spanning longer still flags) are now discarded.
Wired through all coupled places: CLI flag, /api/config, controls-bar
input, /api/analyze query param, and the analysis-cache head keys (old
two-key caches no longer match and are recomputed on next analyse).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the fixed RMS threshold with prominence over a rolling noise
floor (20th percentile per 30s block, min-smoothed so events cannot
raise their own floor, clamped to -54 dBFS). Slow ambience changes such
as rain or daytime traffic hum move the floor instead of flagging
everything; sections now need `margin` dB (default 12) of prominence.
Each section carries a score (peak dB above floor); day-highlight chips
show the top 50 by score when there are too many to list, so the most
striking events are reviewed first.
--threshold is replaced by --margin; analysis caches are now keyed by
margin+min_gap, old threshold-keyed caches never match and are
overwritten on the next analyse. Detector covered by tests/test_web.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>