Commit Graph
14 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 2a19515c27 feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:05:15 +02:00
adminandClaude Opus 5 c91264c253 refactor: split web.py — detector and audio-format helpers into isrweb/
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>
2026-07-30 23:14:54 +02:00
adminandClaude Opus 5 138f6db0a5 refactor: one Range-capable file sender; HEAD support; assorted hygiene
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>
2026-07-29 21:29:57 +02:00
adminandClaude Opus 5 a836948882 fix: order the file listing by the filename clock, not mtime
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>
2026-07-29 20:31:27 +02:00
adminandClaude Opus 5 b661e166ed fix: detector rejects non-16-bit WAV and stops padding the last section
_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>
2026-07-29 20:30:46 +02:00
adminandClaude Opus 5 9bb1229aa9 fix: two error paths that made a failure worse than the thing that failed
/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>
2026-07-29 20:29:54 +02:00
adminandClaude Opus 5 1541110ffb refactor: collapse the single-implementation backend abstraction, fix device resolution
isr.py carried a plugin architecture — AudioBackend ABC, a _backend_classes
registry, _discover_backends, available_backends/get_backend, a
preferred_backend config knob — for exactly one implementation. It was
also the source of three real bugs:

* find_device() returned None before reaching the ALSA PCM-name fallback
  whenever `arecord -l` enumerated nothing, so the documented
  `device = shared_mic` (dsnoop from asound.conf) case failed outright on
  any host with no hardware card. The fallback is the whole point: those
  PCMs never appear in `arecord -l`.

* `device = monitor` could never resolve. ALSA has no generic monitor
  source and list_devices hardcoded is_monitor=False, so the keyword —
  advertised in config.example.ini as the "EASY" way to capture system
  audio — only ever produced "device not found". Removed; it is a
  PulseAudio idea that outlived the PulseAudio backend.

* list_all_devices() deduplicated by lowercased name "in case a device
  appears in multiple backends". With one backend what it actually did
  was delete the second of two identical USB cards. Dedup dropped.

find_device() now always returns a device, so _resolve_device's
unreachable "Available devices:" error is gone; an unknown spec is
passed to ALSA verbatim and logged. AudioDevice loses backend/
is_monitor plus channels/sample_rate — the latter were hardcoded 2/44100
and only ever printed by --list-devices, i.e. numbers with no bearing on
what gets recorded.

--list-devices now says outright that it lists hardware cards only, which
is where the shared_mic confusion started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:28:30 +02:00
adminandClaude Opus 5 46af41b12d fix: soundcard capture detects a dead arecord instead of silently recording nothing
ALSAStream._read_loop just returned when arecord died or hit EOF, and
__exit__ never checked the exit status. SoundcardRecorder.record's inner
loop only slept and flushed, so nothing ever noticed: the recorder stayed
"running", kept rolling over to a new split file on schedule, and wrote
zero bytes into every one of them. The max_retries/reconnect path below
it was unreachable. Worst kind of failure for an unattended recorder —
files keep appearing, at the right times, with the right names.

ALSAStream now tracks when audio last arrived and exposes check_alive(),
which raises if the process exited or nothing has been captured for
SILENCE_TIMEOUT (15 s). The record loop calls it every 0.5 s tick, so a
dead device unwinds into the existing retry handler.

retry_count resets only when the stream actually delivered audio —
resetting on every healthy tick would let a device that opens but never
captures retry forever, which is the same silent failure by another
route.

Also: join the stderr thread in __exit__, and break _read_loop on EOF
rather than looping until poll() catches up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:21:05 +02:00
adminandClaude Fable 5 f6031cfa16 feat: onset-aware section scoring so slow swells rank at the bottom
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>
2026-06-11 14:57:19 +02:00
adminandClaude Opus 4.8 5e7620627b feat: name cut clips by wall-clock time; fix recording filename format
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>
2026-06-11 14:30:30 +02:00
adminandClaude Fable 5 f3716d3ff1 feat: minimum section duration filter (--min-duration, default 0.5 s)
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>
2026-06-11 09:00:37 +02:00
adminandClaude Fable 5 c84b7d8222 feat: adaptive noise-floor loudness detection with section scoring
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>
2026-06-10 15:36:48 +02:00
adminandClaude Sonnet 4.6 9ba084107b chore: remove dead test code and fix stale comment
tests/test_isr.py:
- Remove unused imports: io, SimpleNamespace, call
- Remove test_mp3_chunks_written_to_file — it mocked connect_stream but
  never called record(), making all the mock scaffolding dead; the actual
  assertion (bytes written to a file) is already covered by
  TestAudioFileWriter and TestGenerateFilename

isr.py:
- Update AudioDevice.backend comment: only the ALSA backend exists now

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:32:34 +02:00
admin 8254ccde86 Add Docker support, fix stale docs, translate UI to English
- Dockerfile + docker-compose.yml: two services (recorder + web) sharing
  ./recordings bind mount; recorder maps /dev/snd for ALSA soundcard access
- requirements.txt: requests, numpy, soundfile
- .dockerignore, updated .gitignore (add __pycache__, .pytest_cache)
- isr.py: add SIGTERM handler for clean Docker shutdown; fix stale error
  message that referenced removed PulseAudio/PipeWire/PortAudio backends
- web.py: translate all German UI strings to English
- config.example.ini: remove PipeWire/PulseAudio/PortAudio backend refs,
  simplify soundcard tips to ALSA only
- README.md: full rewrite as user guide (quick start, config reference,
  Docker notes, how it works)
- CLAUDE.md: update architecture section to reflect ALSA-only backend
- Delete changelog.txt and guide.md (internal session notes)
2026-04-26 10:56:55 +02:00