Compare commits

..

43 Commits

Author SHA1 Message Date
admin 89c95a70a2 feat: loudness walk (U/I) keeps its own cursor, independent of J/K
The clip bar had one shared cursor, so pressing U/I re-derived the
loudness rank from wherever you currently were. A J/K detour to review
clips around an interesting spot therefore hijacked the loudness walk:
returning to U/I continued from the J/K position, not from where the
ranking left off.

Add a separate scoreCursor that only U/I (and "highlights only" auto-
advance) move; J/K never touches it. So: U/I to a loud moment, J/K to
review the time-adjacent clips, U/I again resumes the ranking exactly
where you left it. scoreCursor resets to -1 on every explicit jump /
queue re-arm (hideClipBar, chip clicks, day-highlights arm) so the next
U/I re-anchors on the selected section.

Also label the position count "by time" (J/K) or "by loudness" (U/I) so
the blind user can hear which dimension the count is in — the two looked
identical before and switched meaning silently when changing keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:28:52 +02:00
admin 46efbd6d75 docs: README clip-bar label and J/K vs U/I navigation accuracy
Bring the clip-playback section in line with the current UI:
- label example now shows the dB prominence and notes the visible label
  and screen-reader announcement are one identical string
- tooltip holds only the filename + in-file offset (score moved into
  the label)
- explain that the position count is the chronological index under J/K
  and the loudness rank under U/I, with the denominator always the full
  set (U/I has no top-N cutoff; only the chip display is capped)
- document that J/K and U/I share one queue and cursor, so U/I to find a
  spot then J/K to review the time-adjacent clips works as expected

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:17:42 +02:00
admin c5c63a76e8 fix: loudness navigation shows rank position, not time-order index
When stepping the clip queue by loudness (U/I, or "highlights only"
auto-advance), the position count showed the section's time-order index
(e.g. "30 / 426") instead of its place in the loudest-first ranking, so
walking highlights produced a count that jumped around. playClip now
takes a byScore flag: the displayed position is the rank in scoreOrder()
when navigating highlights ("1 / 426" = loudest), the time-order index
otherwise. The in-player U/I path already announced the rank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:41:12 +02:00
admin 8a532fcf57 fix: clip label and screen-reader announcement use one identical string
The clip bar's visible label and its aria-live announcement were two
different formats ("17:09:56 to 17:09:57 (30 / 426)" vs "Clip 368 of
426: 21:25:44 to 21:25:50"). Build one string and use it for both, and
include the section's dB prominence (as the highlight chips do):

  17:09:56 to 17:09:57 · +30 dB (30 / 426)

The dB moves out of the hover tooltip into the spoken/visible label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:36:35 +02:00
admin 682b0522d3 feat: Shift+J/K/U/I jump to first/last/loudest/quietest section
Holding Shift with the section-navigation keys jumps straight to the
extreme in that direction instead of stepping one at a time:
- Shift+J / Shift+K -> first / last section in time order
- Shift+U / Shift+I -> loudest / quietest section

Works in both clip-queue navigation (stepClip gains a jump flag) and
in-player full-file navigation. Visible key-hint note and README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:33:43 +02:00
admin 5a9518e262 fix: screen-reader fixes for day highlights button and chips toggle
The Highlights button's aria-label overrode its content, so the visible
"· analysed" suffix was never announced — the analysed state is now
mirrored into the label (at render and when a highlights run completes).
The chips toggle's accessible text started with a stray space (left over
once the aria-hidden arrow is dropped), which read as an indent; the
separator space now lives inside the aria-hidden arrow span.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:16:39 +02:00
admin 9c58e35546 feat: drop rms_display from /api/analyze
The UI no longer draws waveforms, so the server stops computing and
sending the ~800-point RMS preview; the payload is now just sections,
duration, window. Old analysis caches stay valid: rms/rms_display are
popped when serving a cache hit (same pattern as the earlier full-RMS
removal), so no DETECTOR_VERSION bump.

Verified: 63 tests pass; endpoint smoke test (fresh + cached analyze)
confirms no RMS keys and correct section detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:02:47 +02:00
admin 91701ce4d3 feat: remove per-file waveform SVG from the analyse view
Same rationale as the day timeline: purely visual, useless via screen
reader. The section count it carried in its aria-label moved into the
meta line ("N loud sections - margin: 12 dB - gap: 2s - min: 0.5s").
drawWave() and the svg.wave CSS are gone; the UI now renders no SVG at
all. /api/analyze still returns rms_display for API stability, but the
bundled UI no longer reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:58:14 +02:00
admin 41d921a42a feat: remove decorative day-timeline SVG, linear highlights panel
The user is blind; the day activity timeline (and its hour labels) was
purely visual, non-interactive, and carried no information not already
in the chips/summary. The highlights panel is now linear text and
buttons in reading order: summary line ("N files analysed - M loud
sections"), key-hint note (now always shown, J/K/U/I is the primary
interface), chips toggle, chips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:49:59 +02:00
admin 653084e90b feat: cleaner day highlights panel
- Day timeline axis now labels round wall-clock hours with tick marks
  on the bar (start/end fallback when the span has <2 round hours),
  replacing the arbitrary start/midpoint/end labels.
- Long chip lists (>12) collapse behind an aria-expanded toggle button
  so the panel is not a wall of 50 buttons; J/K/U/I are unaffected.
- Chips group aria-labels shortened to "Day loud sections" / "Loud
  sections" - the key-binding explanation lives only in the visible
  note, not repeated in the group name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:39:00 +02:00
admin 98d2d7085d feat: auto-advance radio, J/K decoupled from highlights, U/I ranked stepping
- J/K and Prev/Next now always step the clip queue in time order; the old
  "Highlights only" checkbox could silently change what J/K did.
- Auto-advance is a three-way radio: don't auto-advance / auto-advance
  (next in time) / auto-advance highlights only (next by loudness). It
  only affects what plays when a clip ends.
- The "Top" highlight-count input is gone. U/I (and highlights-only
  auto-advance) walk the full loudest-first ranking from scoreOrder()
  with no top-N cutoff - review simply stops when the user stops.
- In-player U/I (full-file playback) step the same ranking, anchored on
  the section under the playhead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:31 +02:00
admin 2b0403d05d feat: wall-clock clip labels, collapsible day Highlights with analysed marker
- Clip bar label is now the wall-clock time of occurrence plus queue
  position ("03:46:20 to 03:46:22 (73 / 187)"); filename and score moved
  to the hover tooltip. Works for both per-file and day queues via an
  absStart epoch on every queue entry, derived from the filename clock
  (listing date), falling back to in-file offsets for non-standard names.
- Day Highlights button toggles the panel; re-expanding reuses the
  already-built results (re-armed J/K queue from dayHlSections) and only
  recomputes when margin/gap/min-duration changed. A "analysed" suffix
  marks days where every file has a cached analysis for the current
  params; fetchAnalysis keeps f.cached_analysis fresh client-side.
- Day timeline now positions files by the filename clock instead of
  mtime-duration, and the three axis labels (span start / midpoint /
  end) carry explanatory tooltips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:28:23 +02:00
admin 9f1a6ff711 feat: O key opens the current clip in the full file
Extracts the "Open in file" button handler into openClipInFile() and
binds O in the shared keydown listener as a keyboard alternative, so
clip review never needs the mouse: J/K/U/I to step, O to drop into the
full recording for context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:24:30 +02:00
admin 13419244e8 fix: clip bar no longer visible before any clip has played
The author rule #clip-bar{display:flex} overrides the UA stylesheet's
[hidden]{display:none}, so the hidden attribute toggled by playClip()/
hideClipBar() had no visual effect: the bar rendered from page load and
the close button appeared to do nothing. Restate display:none for the
hidden state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:21:41 +02:00
admin 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
admin 6431918989 docs: add a "too many sections per day" tuning note to the README
Consolidates the existing per-knob hints (margin, grace period, min
duration, highlights-only review) into one ordered list of what to
adjust when the detector flags too much, and why ranking by score is
preferable to thinning the list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:43:30 +02:00
admin f52eb62215 feat: U/I keys and "Highlights only" mode to review top-scored sections
J/K still steps through every queued section in time order; U/I steps
through only the highlights, defined as the top-N sections by score
(new "Top" input in the clip bar, default 50, matching the day chips).
A "Highlights only" checkbox makes J/K, Prev/Next, and Auto-advance
skip non-highlights too, so a day with thousands of detections plays
as a short reel of just the loudest events. Both key pairs also work
during full-file playback, and modified keypresses (Ctrl+K, Ctrl+U)
are no longer hijacked from the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:38:56 +02:00
admin 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
admin 2caf23f17d style: replace emoji glyphs in the UI with plain text labels
Buttons read Play/Hide, Cut, Delete, Download, Highlights, Prev/Next,
Open in file, Refresh, Clear; the clip-bar close button uses a
typographic multiplication sign. Day disclosure arrows switch to the
text-presentation triangles (U+25B8/U+25BE) so they can never render as
colored emoji. The red REC dot stays: U+25CF is text-presentation and
takes the badge's CSS color.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:03:32 +02:00
admin 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
admin e4d82483b5 docs: code map, regression findings, and HTTP API reference
CLAUDE.md gains a function-level code map of web.py/webui.html, a
"verifying changes" section (test scope, no-node UI checks, endpoint
smoke pattern, Windows commit-message gotcha), and findings recorded as
guard rails: why detection must stay adaptive (fixed threshold produced
~600 useless sections/day), why section playback must stay clip-based
(libsndfile FLACs have no SEEKTABLE so browser seeks bisect the file),
and the five places analysis params are coupled.

README gains the adaptive-detection and clip-review features up top, an
HTTP API table for scripting, and a corrected Docker analyses-cache
paragraph (the rw mount is ./recordings/analyses layered over the ro
recordings mount, not a separate ./analyses dir).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:21:37 +02:00
admin 119e631faf feat: instant section playback via server-rendered clips
Add /api/clip: decodes a WAV/FLAC slice server-side and returns a small
standalone 16-bit WAV with exact Content-Length (capped at 600s, cached
client-side since finished recordings are immutable). Active recordings
are refused like analyse/cut/delete.

Section chips and J/K now play these clips through a bottom player bar
instead of seeking the full recording - FLACs have no seek table, so
browser seeks bisected hundreds of MB with Range requests and playback
lagged or never started. The bar steps through a queue (one file's
sections or a whole day's via Highlights), auto-advances to the next
section on end for continuous review, and "Open in file" jumps to the
same position in the full recording for context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:13:39 +02:00
admin 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
admin 16dd7cbe51 feat: duration and seeking for in-progress FLAC recordings
FLAC duration cannot be derived from byte size (variable compression),
so unlike WAV the header cannot be patched from st_size alone. Instead,
every FLAC frame header carries its own frame/sample number: read the
last 64 KB of the growing file, scan backwards for a frame sync,
CRC-8-verify the header to reject false matches in compressed data,
and compute the exact samples recorded so far. STREAMINFO
total_samples (36 bits at a fixed offset) is rewritten in the served
bytes only - the on-disk file is never touched.

Overhead: one tail read per /stream request, active files only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:37:55 +02:00
admin fa055fc80a perf: do not hold the audio buffer lock during disk writes
_flush_buffer_to_file wrote (and for FLAC, encoded) every chunk while
holding buffer_lock, blocking the capture callback for the duration of
each disk flush. Swap the buffer out under the lock and write outside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:29:21 +02:00
admin 8e496ec2c4 perf: faster page loads, live-recording playback and seeking fixes
Server (web.py):
- /api/analyze no longer returns the full per-window RMS array (~45x
  larger than the rms_display the UI actually renders); old caches are
  stripped on read
- /api/files reads only the first 256 bytes of each analysis cache to
  get threshold/min_gap instead of parsing the whole JSON
- durations cached by (mtime, size) instead of re-opening every audio
  header per request; stat() race with deleted files guarded
- /api/storage no longer walks the recordings tree (used bytes now
  computed client-side from the file list)
- HTTP/1.1 keep-alive enabled; short writes force-close the connection;
  client-disconnect tracebacks from aborted seeks silenced
- all file copies bounded by the advertised Content-Length so files
  growing during a response cannot desync the connection

Live recording playback:
- /stream/ patches in-progress WAV headers to the current file size so
  browsers show real duration and can seek (on-disk header says 0
  frames until the recorder closes the file)
- active files served with Cache-Control: no-store
- reopening the player for a recording file reloads the source to pick
  up newly captured audio

UI loading:
- analyses lazy-load only for expanded day groups; collapsed days defer
  fetching until opened, and auto-load only when cached parameters
  match the current controls (no surprise mass recompute)
- client-side analysis cache shared by file rows and day highlights, so
  re-renders and filters never refetch
- filename filter debounced (200 ms)
- file list auto-refreshes when the active recording set changes,
  unless audio is playing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:29:13 +02:00
admin c445eb3e04 docs: slim CLAUDE.md down to rules, file map, and non-obvious internals
Drop class-by-class architecture listings that are derivable from the
code; keep only constraints a model cannot infer (status.json coupling,
header injection, read-only mount, shutdown deadline, dsnoop/ipc).
Add webui.html to the file map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:50:09 +02:00
admin 907fd90a5e refactor: extract web UI page into webui.html
web.py was 1700 lines, more than half of it a single embedded HTML
string. The page now lives in webui.html (loaded once at startup),
so the frontend gets real syntax highlighting and web.py is pure
Python. Dockerfile copies the new file alongside web.py.

Also: ASCII startup banner (the arrow glyph crashed web.py on Windows
when stdout was redirected to a cp1252 file), and README fixes —
document the ALSA PCM-name device fallback and drop the monitor
device row, which the ALSA backend never supported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:49:35 +02:00
admin 2e3945dfa0 refactor: deduplicate web UI JS and improve screen reader support
- Remove fmtT (identical duplicate of fmtDur)
- Extract closePlayer() shared by togglePlayer, day collapse, and
  cross-file day section jumps
- Reuse seekToSection from jumpToDaySection instead of a copied
  open/seek/announce block
- Fix chip semantics: role=listitem on <button> overrode the button
  role for assistive tech; use role=group on the container instead
- Drop redundant aria-hidden toggling on the REC badge (hidden already
  removes it from the accessibility tree)
- Respect prefers-reduced-motion for the REC pulse animation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:44:30 +02:00
admin b9089f9c18 refactor: deduplicate web.py server code
- Extract _is_active() helper for the three duplicated status.json
  active-recording checks (analyze, delete, cut)
- Extract _copy_to_response() for the four duplicated 64 KB chunk
  copy loops (download, stream full/range, cut)
- Inline _get_wav_info into _get_audio_duration (sample rate and
  channels were never used)
- Remove unused HTTPServer import, dead frame_pos counter, and the
  redundant (ValueError, Exception) catch in _safe_path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:42:45 +02:00
admin 792f2b1fd5 refactor: remove dead code from isr.py
- Drop unused struct import, AudioDevice.extra/description fields
- Remove unused get_preferred_backend() and the backend priority
  machinery (only one backend exists)
- Deduplicate SoundcardRecorder.close_current_file via super()
- Remove duplicate config-exists check in main()
- Simplify --list-devices output: drop dead monitor grouping and the
  nonexistent pipewire backend example

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:41:37 +02:00
admin 4539ff78fa feat: persist analyses on reload, add re-analyse button and metadata, trim highlights tally
- _api_files: read each file's analysis cache and return cached_analysis
  {threshold, min_gap} so the client knows upfront which files are cached
- On page load: files with a cached analysis auto-fetch and render their
  waveform immediately instead of showing an idle Analyse button
- After any analysis: show threshold/gap/cached metadata line below the
  waveform and a Re-analyse button inside the waveform box; old button
  no longer disappears on success
- Error path now always re-adds an actionable Analyse button to the cell
- dayHighlights progress: remove the redundant intermediate "N analysed"
  tally (16/22 counter already conveys progress); done message now says
  "N files (N from cache)" when relevant
- Threshold hint updated to show dBFS equivalent (0.05 ≈ −26 dBFS)
- CSS: remove unused .prog-tally and .cached-badge; add .analysis-meta
  and .reanalyse-btn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:32:04 +02:00
admin 77e7e4ca9e fix: resolve NameError on startup, move analyses into recordings, remap port to 8050
- web.py: Python class bodies can't close over a name they also assign;
  use a temporary alias _analyses_dir to break the self-reference
- docker-compose.yml: mount ./recordings/analyses:/recordings/analyses
  instead of a separate ./analyses volume so cache lives inside recordings;
  remove --analyses-dir flag (default now resolves correctly); remap
  external port 8080→8050 for reverse proxy
- README.md / CLAUDE.md: update Docker port references to 8050

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 08:46:49 +02:00
admin 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 4aea07ae40 fix: separate analyses dir so caching works with read-only recordings mount
The Docker web container mounts ./recordings as :ro, causing every cache
write to fail silently (PermissionError swallowed by bare except).

Fix: add --analyses-dir flag (default: <recordings>/analyses for local runs).
docker-compose.yml adds ./analyses:/analyses (writable) and passes
--analyses-dir /analyses to web.py. Cache write failures now print a
warning instead of being swallowed silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:29:09 +02:00
admin b6b328dfb8 fix: switch audio to preload=auto when player opens or seek is triggered
preload='metadata' only fetches the header; every seek then requires a
fresh Range request and buffering delay. Switching to 'auto' lets the
browser start buffering the file immediately so seeking into it is fast.
Set both in togglePlayer (on open) and in seekToSection/jumpToDaySection
(in case the player was already open with the old metadata-only mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:24:17 +02:00
admin a68af56421 fix: cap day-highlights chips at 50; show J/K hint when over limit
Rendering 793 individual buttons is both visually overwhelming and slow.
When a day has more than 50 loud sections, replace the chip list with a
single note ("N sections — use J / K to navigate"). J/K navigation and
the SVG timeline still cover all sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:23:54 +02:00
admin 7821f8823d feat: show analysis progress with per-file counter and cached tally
Day highlights: replaces the static "Analysing N files…" spinner with a
live progress bar, current filename ("3 / 8 — recording.wav"), and a
running tally of cached vs freshly-analysed files. Completes with a
summary line ("Done — 8 files (6 cached, 2 analysed)").

Single-file analyse: adds a small "cached" badge in the waveform box
when the result was served from cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:40:19 +02:00
admin 75434ca96d feat: flag cached analysis responses with cached:true
Allows the UI to distinguish instant cache hits from live computation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:39:34 +02:00
admin eb774a0876 feat: move analysis cache to recordings/analyses/, prune orphans on startup
- Cache files now live in recordings/analyses/<filename>.analysis.json
  (mirroring the relative path for files in subdirectories) rather than
  alongside each audio file.
- _api_delete now removes the corresponding cache file after deleting audio.
- prune_orphan_analyses() runs at startup and removes any .analysis.json
  whose audio file no longer exists (handles files deleted outside the UI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:33:26 +02:00
admin e22c0059f6 feat: cache analysis results alongside audio files
After the first analysis of a WAV/FLAC file, the result is written to
<filename>.analysis.json next to the audio. Subsequent requests with the
same threshold and min_gap parameters return the cached result immediately
without re-reading the audio data. The cache is invalidated automatically
if either parameter changes. Written via temp-then-replace for thread safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:30:12 +02:00
admin df32c263bc chore: remove NTFY notification roadmap
Not being pursued — the planning doc just adds noise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:29:42 +02:00
admin af8113ba03 fix: auto-play audio when jumping to a loud section
seekToSection and jumpToDaySection were only seeking (setting currentTime)
but never calling play(), so the player would open and position correctly
but stay paused. The loadedmetadata-deferred path already handles slow audio
loading; play() is now called there too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:28:20 +02:00
11 changed files with 2189 additions and 1484 deletions
+49 -73
View File
@@ -1,93 +1,69 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code when working in this repository.
## Rules ## Rules
- **Always update `README.md`** whenever user-facing behaviour changes (new flags, new endpoints, changed Docker setup, new features). The README is the primary external reference; CLAUDE.md documents internals. - **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals.
- **Always commit `README.md`** in the same commit as the code changes it documents — never let the README fall behind. - Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector).
## Project Overview ## Files
ISR is a Python audio recording application that captures from multiple simultaneous sources (Icecast/HTTP streams and ALSA soundcard devices) with time-based file splitting. All application code is in two files: `isr.py` (recorder) and `web.py` (archive browser UI). | File | Purpose |
|------|---------|
| `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits |
| `web.py` | Archive browser: HTTP server, file listing, RMS loudness analysis, cut/delete |
| `webui.html` | Single-page UI (HTML/CSS/JS), loaded by `web.py` at startup — must sit next to `web.py` and be copied in the Dockerfile |
| `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) |
| `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard |
## Commands ## Commands
```bash ```bash
# Run the recorder python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
python isr.py # uses config.ini python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --min-duration, --analyses-dir)
python isr.py myconfig.ini # custom config file python -m pytest tests/ # test suite
python isr.py --list-devices # list available ALSA devices docker compose up -d / down # web UI mapped to host port 8050
# Run the web UI
python web.py # http://localhost:8080
python web.py --dir recordings # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05)
# Stop: Ctrl+C (or docker compose down)
# Install dependencies
pip install requests # for stream recording
pip install numpy soundfile # for FLAC output and web waveform analysis (optional)
# Docker
docker compose up -d
docker compose logs -f
docker compose down
``` ```
## Architecture Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC analysis/clips — both optional, code degrades gracefully).
### Audio Backend System ## Code map
- **AudioDevice** — Dataclass: id, name, channels, sample_rate, backend type
- **AudioBackend** (ABC) — Abstract base for audio capture backends
- **ALSABackend** — Native ALSA support via `arecord` subprocess (the only backend)
- **ALSAStream** — Context manager that wraps an `arecord` subprocess and reads PCM in a thread
- **AudioSystem** — Discovers available backends, lists devices, resolves device specs
### Recorder Classes `web.py`:
- **BaseRecorder** (ABC) — Common settings, `get_next_split_time()`, `generate_filename()`, `record()` interface - Detection: `_compute_rms_windows_wav()` / `analyze_flac()` produce 100 ms RMS windows → `_noise_floor_db()` estimates the rolling floor → `_loud_sections()` emits scored sections → `_package_result()` shapes the `/api/analyze` payload.
- **StreamRecorder(BaseRecorder)** — Records HTTP/Icecast streams with format auto-detection and OGG/FLAC header injection - Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice, `_wav_header()` builds the 44-byte PCM header.
- **SoundcardRecorder(BaseRecorder)** — Records from ALSA devices; outputs WAV or FLAC via `_AudioFileWriter` - Filenames as a clock: `_recording_start()` parses the start time out of a filename stem; `_cut_filename()` turns a (stem, ext, start, end) into a wall-clock-named cut. Both the listing `date` field and `_api_cut()` use them.
- **_AudioFileWriter** — Unified write/close interface for wave (WAV) and soundfile (FLAC) - Live headers: `_live_wav_header()`, `_live_flac_header()` (+ `_flac_frame_samples()`, CRC-8 verified).
- **RecorderManager** — Loads config, creates recorders, manages threads, handles shutdown - Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard).
### Key Implementation Details `webui.html` (one `<script>` block):
- ALSA backend spawns `arecord` as a subprocess; raw PCM is read in 100 ms chunks via a reader thread - Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false.
- Device selection: `default`, `monitor` (loopback), partial name match, or exact `hw:X,Y` ID - Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtimeduration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span.
- Thread-safe audio buffering with `threading.Lock()` - J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest.
- OGG/Opus/FLAC headers captured from first ~16 KB of stream and prepended to each split file - Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user), `cachedParamsMatch()` (autoload guard).
- File splits aligned to time period boundaries (`get_next_split_time()`)
- SIGTERM handled in `main()` so Docker `docker compose down` shuts down cleanly
- `RecorderManager._write_status()` atomically writes `recordings/status.json` every 2 s while running; deleted on clean shutdown so the web UI shows no stale active-recording badges
### Web UI (web.py) ## Verifying changes
- **`GET /`** — Single-page archive table; lists all recordings sorted newest first
- **`GET /api/files`** — JSON list of file metadata (name, size, date, duration, ext, recording flag)
- **`GET /api/analyze?file=<path>`** — RMS loudness analysis for WAV and FLAC files; returns waveform data, loud sections, and duration. Requires `numpy` and `soundfile` for FLAC.
- **`GET /api/status`** — Returns `{"active": [...]}` from `status.json`; used by the UI to animate the REC badge on in-progress files (polled every 5 s)
- **`GET /stream/<path>`** — Serves audio for inline `<audio>` playback with full HTTP Range support (seekable). Responds 206 Partial Content for range requests. Files are served with `Content-Disposition: inline`.
- **`GET /download/<path>`** — Serves audio as a file download (`Content-Disposition: attachment`)
- All paths are validated against the recordings directory to prevent path traversal.
## Configuration - `python -m pytest tests/` covers the recorder (`test_isr.py`) and the detector (`test_web.py`).
- There is no JS toolchain and no `node` on the dev box. After editing `webui.html`, cross-check every `getElementById('x')` against an `id="x"` declaration, and smoke-test endpoints.
- Endpoint smoke pattern: write a temp WAV/FLAC with a known loud burst, subclass `web._Handler` with `recordings_dir`/`analyses_dir` pointing at the temp dir, serve `web._Server(('127.0.0.1', 0), H)` in a daemon thread, then hit `/api/analyze` and `/api/clip` with urllib — assert section start/score and that `Content-Length == len(body) == 44 + frames × channels × 2`.
- Dev box is Windows / PowerShell 5.1. Multi-line commit messages: use the Bash tool with `git commit -F - <<'EOF'` — PowerShell here-strings containing quotes get mangled into separate arguments.
Copy `config.example.ini` to `config.ini`. Each section defines a recording source: ## Non-obvious internals
- `type = stream` — HTTP/Icecast stream recording
- `type = soundcard` — ALSA device recording
The `output_directory` value is used as-is: a relative path like `recordings` resolves to `recordings/` next to `isr.py`. No Docker-specific config change is needed — the docker-compose.yml mounts `./recordings` at `/app/recordings` to match this default. - **Recorder/web coupling is one file:** `RecorderManager` atomically writes `recordings/status.json` every 2 s listing in-progress files; deleted on clean shutdown. `web.py` reads it to show REC badges and to refuse analyse/cut/delete on active files. In-progress WAV/FLAC headers are unfinalized, so durations are not read for active files.
- **Stream splits:** OGG/Opus/FLAC codec headers are extracted from the first ~16 KB of each connection and prepended to every split file so each file plays standalone. A new file is always opened on reconnect (gap in stream). MP3/AAC need no headers.
## Docker - **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
- **Filename is the clock — fixed format, not configurable.** Recordings are named `%Y%m%d_%H%M%S.<ext>` (the *start* time). This is hardcoded as `FILENAME_FORMAT`, defined in **both** `isr.py` (recorder writes it) and `web.py` (reads it back) — the two copies must stay in sync. There is no `filename_pattern` config option (removed; `web.py` can't see `config.ini`, so a configurable pattern would break parsing). `web.py` derives the displayed DATE column from the filename via `_recording_start()` (falling back to mtime only for non-standard names — mtime is the last write ≈ end, not the start). Cut downloads are named by the wall-clock span they cover via `_cut_filename()`: a 22:31:30→22:32:30 slice of `20260523_220000.flac` becomes `20260523_22-31-30_22-32-30.flac`; non-standard source names fall back to `<stem>_cut_<start>s-<end>s`.
Two services share a `./recordings` bind mount: - **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`).
- `recorder` — runs `isr.py`; volume at `/app/recordings`; mounts `asound.conf` as `/etc/asound.conf`; maps `/dev/snd`; `ipc: host` for dsnoop shared memory; `stop_grace_period: 30s` - **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
- `web` — runs `web.py`; same `./recordings` read-only at `/recordings`; exposes port 8080 - **Loud-section detection is adaptive — do not regress it to an absolute threshold.** Per-window dB is compared against a rolling noise floor (`NOISE_PERCENTILE`-th percentile per `NOISE_BLOCK_SECONDS` block, min-smoothed over ±2 blocks so events can't raise their own floor; clamped to ≥ `MIN_RMS`). A section needs `margin` dB of prominence and carries a `score` used for ranking: peak dB above floor, **capped by the sharpest rise within `ONSET_SECONDS` (0.5 s)** — so a short (~10 s) swell that outruns the 30 s floor blocks still flags but scores ≈ 0 and sinks in the U/I highlight ranking, while sharp events keep their full prominence. A section starting in the first 0.5 s of a file is scored against the floor instead (events cut off by a file split must not be punished as swells). Do not regress the scoring to raw peak, and do not fight swells with a higher margin. If flagging itself (not just ranking) ever needs improving, the next step is a spectral filter or optional Silero VAD over candidate sections. Sections shorter than `min_duration` (default 0.5 s, after `min_gap` merging) are discarded — without this, isolated 100 ms pops (clicks, single raindrops) produced thousands of zero-length sections per day. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Tests in `tests/test_web.py`.
- **Analysis params are coupled in five places.** CLI `--margin`/`--min-gap`/`--min-duration``/api/config` → UI inputs `#margin-input`/`#min-gap-input`/`#min-duration-input``/api/analyze` query params → cache JSON head keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` and the `_cached_analysis_params()` regex (see the threshold→margin change `c84b7d8` and the min_duration addition).
**Sharing the soundcard with darkice (or any other ALSA app):** - **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so docker-compose layers a read-write `./recordings/analyses` bind mount over it. The `detector`, `margin`, `min_gap`, and `min_duration` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. `detector` is `DETECTOR_VERSION`: bump it whenever detection/scoring changes make old cached results wrong (e.g. v2 = onset-capped scores); caches with another version (or missing keys) never match and get overwritten on the next analyse.
ALSA `hw:` devices are exclusive. `asound.conf` defines a `dsnoop` virtual device `shared_mic` that both processes use instead: - **Analyze responses:** `/api/analyze` returns only `sections`, `duration`, `window` — no RMS data of any kind. `rms_display` (~800-point waveform preview) and the full per-window list were dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed `rms`/`rms_display` are still valid; both keys are popped when serving from cache, so no DETECTOR_VERSION bump was needed.
1. `sudo cp asound.conf /etc/asound.conf` on the host - **Section playback uses clips, not seeks:** `/api/clip?file&start&end` decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at `CLIP_MAX_SECONDS`), `Cache-Control: private` so re-listening is free. The UI plays chips/J-K through the bottom clip bar (`clipQueue` in webui.html); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC **without a SEEKTABLE**, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in `<audio>` is inherently slow and must not be reintroduced as the primary navigation. Server-side `sf.SoundFile.seek()` on local disk is fast and frame-accurate.
2. Change darkice config to `device = shared_mic` - **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve).
3. Set `device = shared_mic` in `config.ini` - **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
4. `ipc: host` in `docker-compose.yml` is already set — required for dsnoop shared memory to cross the container boundary - **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir.
- **dsnoop in Docker:** sharing the soundcard requires `asound.conf` on the host *and* `ipc: host` in docker-compose (dsnoop uses shared memory across the container boundary).
+1 -1
View File
@@ -10,7 +10,7 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY isr.py web.py ./ COPY isr.py web.py webui.html ./
RUN mkdir -p /recordings RUN mkdir -p /recordings
+56 -24
View File
@@ -13,7 +13,9 @@ Records from multiple simultaneous sources — Icecast/HTTP streams and ALSA sou
- OGG / Opus / FLAC header injection so every split file is independently playable - OGG / Opus / FLAC header injection so every split file is independently playable
- Auto-reconnect on stream drops or device errors - Auto-reconnect on stream drops or device errors
- WAV and FLAC output for soundcard sources - WAV and FLAC output for soundcard sources
- Web UI to browse, download, and analyse recordings - Web UI to browse, play, cut, and download recordings — including files still being written
- **Loud-event detection** — adaptive: events are flagged by how far they rise above the rolling background noise, not by absolute level, and ranked by score
- **Clip review** — detected sections play as instant server-rendered clips with auto-advance, so a whole day can be reviewed like a highlights reel
--- ---
@@ -21,7 +23,7 @@ Records from multiple simultaneous sources — Icecast/HTTP streams and ALSA sou
```bash ```bash
pip install requests # stream recording pip install requests # stream recording
pip install numpy soundfile # FLAC output + web waveform analysis (optional) pip install numpy soundfile # FLAC output + web analysis/clips for FLAC (optional)
cp config.example.ini config.ini cp config.example.ini config.ini
# edit config.ini to add your sources # edit config.ini to add your sources
@@ -36,7 +38,7 @@ cp config.example.ini config.ini
# edit config.ini to add your sources (no path changes needed for Docker) # edit config.ini to add your sources (no path changes needed for Docker)
docker compose up -d docker compose up -d
# recorder starts immediately; web UI at http://<host>:8080 # recorder starts immediately; web UI at http://<host>:8050
docker compose logs -f # tail logs from both services docker compose logs -f # tail logs from both services
docker compose down # graceful stop (waits up to 30 s for files to close) docker compose down # graceful stop (waits up to 30 s for files to close)
``` ```
@@ -65,7 +67,6 @@ docker compose up -d --build
|-----|---------|-------------| |-----|---------|-------------|
| `output_directory` | `recordings` | Output path relative to the working directory (or absolute). The Docker setup mounts `./recordings` at `/app/recordings` so this default works unchanged. | | `output_directory` | `recordings` | Output path relative to the working directory (or absolute). The Docker setup mounts `./recordings` at `/app/recordings` so this default works unchanged. |
| `split_minutes` | `60` | Split into a new file every N minutes, aligned to clock boundaries (e.g. 60 → files start at :00, 30 → at :00 and :30). | | `split_minutes` | `60` | Split into a new file every N minutes, aligned to clock boundaries (e.g. 60 → files start at :00, 30 → at :00 and :30). |
| `filename_pattern` | `%Y%m%d_%H%M%S` | strftime pattern; file extension is appended automatically. |
| `max_retries` | `10` | Give up after this many consecutive failures per source. | | `max_retries` | `10` | Give up after this many consecutive failures per source. |
| `retry_delay_seconds` | `5` | Wait between retries. | | `retry_delay_seconds` | `5` | Wait between retries. |
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` | | `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` |
@@ -102,9 +103,9 @@ format = wav # wav | flac
| Value | Behaviour | | Value | Behaviour |
|-------|-----------| |-------|-----------|
| `default` | System default input | | `default` | System default input |
| `monitor` | First loopback/monitor source (capture system audio) |
| `<partial name>` | Case-insensitive substring match against device name | | `<partial name>` | Case-insensitive substring match against device name |
| `hw:X,Y` | Exact ALSA hardware ID | | `hw:X,Y` | Exact ALSA hardware ID |
| `<pcm name>` | Any ALSA PCM defined in `asound.conf` (e.g. the `shared_mic` dsnoop device), even if it doesn't appear in `arecord -l` |
Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs. Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs.
@@ -122,25 +123,17 @@ split_minutes = 60
[radio1] [radio1]
type = stream type = stream
url = http://radio.example.com:8000/stream1 url = http://radio.example.com:8000/stream1
filename_pattern = radio1_%Y%m%d_%H%M%S
[system_audio] [system_audio]
type = soundcard type = soundcard
device = hw:0,0 device = hw:0,0
filename_pattern = system_%Y%m%d_%H%M%S
``` ```
--- ---
## Filename patterns ## Filenames
strftime codes are substituted at split time. The file extension is added automatically. Recordings are named `<YYYYMMDD>_<HHMMSS>.<ext>` from the time the file is opened — its **start** time — e.g. `20241225_143000.flac`. This format is fixed and not configurable: the web UI parses the start time back out of the filename to show the recording date and to name cut clips with real wall-clock times (see below).
| Pattern | Example |
|---------|---------|
| `%Y%m%d_%H%M%S` | `20241225_143000.mp3` |
| `radio_%Y-%m-%d_%H%M` | `radio_2024-12-25_1430.mp3` |
| `%Y/%m/%d/rec_%H%M%S` | `2024/12/25/rec_143000.mp3` *(subdirs created automatically)* |
--- ---
@@ -150,23 +143,60 @@ strftime codes are substituted at split time. The file extension is added automa
python web.py # serves ./recordings on port 8080 python web.py # serves ./recordings on port 8080
python web.py --dir /path/to/audio # custom recordings directory python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold 01 (default 0.05) python web.py --margin 15 # dB above background noise for a section to count as loud (default 12)
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2) python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
python web.py --min-duration 1 # discard loud sections shorter than this many seconds (default 0.5)
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
``` ```
The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at startup — keep the two files together.
Shows recordings grouped by day with collapsible sections. Features: Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes. - **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
- **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. - **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. The panel is plain text and buttons in linear reading order (screen-reader friendly): files-analysed/section totals, a key hint, then the section chips. When a day has more sections than fit as chips, the chips are the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; long chip lists are collapsed behind a toggle button so the panel stays compact. J/K still steps through all sections in time order, and U/I steps through them by loudness. Holding **Shift** jumps straight to an extreme: **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest.
- **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play. - **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. - **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it. - **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it.
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing. - **Min duration** — configurable in the controls bar (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable.
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). - **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. The player bar labels each clip with the wall-clock time it occurred (derived from the recording's filename), its loudness above the background, and its position in the queue, e.g. `17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)` — and the exact same text is announced to screen readers, so the spoken and visible labels never differ. The filename and in-file offset are in the label's hover tooltip. **J** / **K** (or the **Prev** / **Next** buttons) always step through the queued sections in time order — one file's, or a whole day's after **Highlights** — and the count is then the chronological index, labelled **by time** (e.g. `30 / 426 by time` is the 30th section in time). **U** / **I** step through the same queue by loudness instead: **I** plays the next-loudest section, **U** goes back up the ranking, and the count becomes the rank in that loudness order, labelled **by loudness** (`1 / 426 by loudness` is the loudest of all reachable sections, `2 / 426 by loudness` the next-loudest, and so on) — the denominator stays the full count because U/I reaches every section, with no top-N cutoff; the chip list shown on screen is capped only for display. So a day with thousands of detections is reviewed loudest-first for as long as it stays interesting — just stop when it gets boring. The loudness walk keeps its own position **independently** of J/K: find a loud moment with **U** / **I**, press **J** / **K** to review the clips immediately before and after it in time, and pressing **U** / **I** again resumes the loudness ranking exactly where you left it — the J/K detour never moves your place in the loudest-first walk. (Clicking a chip is an explicit jump, so it re-anchors the next U / I there.) The auto-advance radio in the player bar picks what happens when a clip ends: **Don't auto-advance**, **Auto-advance** (next section in time), or **Auto-advance highlights only** (next section by loudness) — it never changes what J/K do. Holding **Shift** with any of these jumps to the extreme in that direction — **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest. The same keys work during full-file playback, seeking the open recording to the next section in time (J/K) or by loudness (U/I). **Open in file** (or the **O** key) switches to the full recording at the same position for context; each chip click also pre-fills the cut panel.
- **Cut & download** — `Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). The cut is named with the real wall-clock span it covers — `<YYYYMMDD>_<HH-MM-SS>_<HH-MM-SS>.<ext>`, e.g. a 22:31:30→22:32:30 slice of a recording started at 22:00:00 becomes `20260523_22-31-30_22-32-30.flac`.
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active. - **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table. - **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` (header is unfinalized until recording stops). - **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` in the table (header is unfinalized until recording stops). The file list refreshes automatically when a recording starts, stops, or rolls over to a new split file (unless audio is playing).
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms. - **Listen while recording** — in-progress files are playable and seekable. For WAV and FLAC the server patches the (still unfinalized) header on the fly so the browser sees the real duration-so-far — for FLAC the exact sample count is parsed from the last frame header in the file tail. Reopening the player reloads the source to pick up newly recorded audio. Live responses are sent with `Cache-Control: no-store`.
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached analyses load only for expanded day groups, and collapsed days fetch nothing until opened.
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management; all information is plain text and buttons in linear reading order, with no visual-only elements.
### Too many sections per day?
The detector is purely energy-based: anything that rises *margin* dB above the rolling background floor for at least *min duration* seconds is flagged. In a quiet environment the floor sits very low, so even modest sounds — a gust, a distant car, rustling — clear the margin, and an outdoor day can easily produce over a thousand sections. What to adjust, in rough order of preference:
1. **Review by rank instead of thinning the list** — use **U**/**I** (or **Auto-advance highlights only**) to step through the sections loudest-first. Scores favour sharp onsets, so slow ambience swells sort themselves to the bottom automatically. Nothing is discarded and there is no cutoff — just stop when the events stop being interesting.
2. **Raise the grace period** (2 → 1530 s) — merges clusters of related noise (a rain burst, one long conversation) into a single section. Cuts the count heavily without dropping any audio from review.
3. **Raise the margin** (12 → 1518 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
4. **Raise min duration** (0.5 → 12 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
Changing any of these re-runs the analysis (results are cached per parameter combination), so experimenting on one day before re-analysing everything is cheapest.
### HTTP API
Everything the UI does goes through these endpoints, so they can also be scripted:
| Endpoint | Description |
|----------|-------------|
| `GET /api/files` | File listing with size, mtime, duration, recording state, cached-analysis params |
| `GET /api/analyze?file=&margin=&min_gap=&min_duration=` | Loud-section analysis: scored `sections`, `duration` |
| `GET /api/clip?file=&start=&end=` | Section of a WAV/FLAC decoded server-side, returned as a standalone WAV (max 600 s) |
| `GET /api/cut?file=&start=&end=` | ffmpeg-trimmed copy of the file as a download |
| `GET /stream/<name>` | Inline playback with HTTP Range support; live files get an on-the-fly patched header |
| `GET /download/<name>` | Raw file download |
| `GET /api/status` | Currently recording files (`status.json` passthrough) |
| `GET /api/storage` | Disk free/total |
| `GET /api/config` | Server-side defaults for margin, min-gap, and min-duration (seeds the UI controls) |
| `DELETE /api/files/<name>` | Delete a recording and its analysis cache |
Analysis, clips, cut, and delete return `409` for files that are still being recorded.
--- ---
@@ -211,6 +241,8 @@ docker compose down && docker compose up -d --build
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount). **Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
**Analysis cache in Docker:** The web container mounts `./recordings` read-only; a separate read-write bind mount of `./recordings/analyses` is layered on top so analysis cache files can still be written. The directory is created automatically by Docker Compose on first run. Cache files are stored as `recordings/analyses/<filename>.analysis.json` on the host.
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host: **File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
```bash ```bash
# Delete recordings older than 30 days # Delete recordings older than 30 days
-154
View File
@@ -1,154 +0,0 @@
# ISR Roadmap
## notify.py — NTFY Loudness Notifications
### Context
Street ambience recorder. Goal: detect notable audio events (speech, thunder,
sustained unusual sounds) in hourly recording files and push a notification via
a self-hosted NTFY server. Generic short events (car horn, passing vehicle)
should be filtered out by a minimum section duration.
### Design decisions
| Topic | Decision |
|---|---|
| Detection | RMS + minimum section duration filter (KISS — no FFT for now) |
| Timing | Configurable: `immediate` / `daily` / `both` |
| Config | `[notify]` section in existing `config.ini` |
| Code structure | `notify.py` imports `analyze_wav` / `analyze_flac` from `web.py` (DRY) |
| Source name | Included in notification body; configurable display name per source |
---
### Config additions (`config.example.ini`)
Add a `[notify]` section to `config.ini`:
```ini
[notify]
enabled = true
ntfy_url = https://ntfy.example.com/mytopic ; full URL incl. topic
mode = immediate ; immediate | daily | both
daily_time = 08:00 ; HH:MM — used in daily and both modes
debounce_minutes = 60 ; immediate mode: suppress repeat notifications within this window
min_section_duration = 2.0 ; seconds — sections shorter than this are ignored (filters car horns etc.)
min_sections = 1 ; number of qualifying sections required to trigger a notification
loudness_threshold = 0.05 ; RMS 01, same scale as web.py analysis threshold
```
Per recording source, add an optional `display_name`:
```ini
[radio1]
type = stream
url = http://icecast.example.com:8000/live
display_name = Street mic north ; shown in notification; defaults to section name [radio1]
```
---
### Notification format
```
Title: ISR — Notable audio · Street mic north
Body: radio1_20260427_0300.wav
3 notable sections (≥ 2.0 s each)
→ 00:12 00:18
→ 01:45 01:52
→ 47:03 47:11
Peak RMS: 0.312
```
Daily digest example:
```
Title: ISR Daily Digest · 2026-04-27
Body: Street mic north — 4 files with notable events
03:00 file · 3 sections (peak 0.312)
07:00 file · 1 section (peak 0.091)
14:00 file · 2 sections (peak 0.204)
21:00 file · 1 section (peak 0.178)
```
---
### Implementation plan
#### Phase 1 — Core
1. **`config.example.ini`** — add `[notify]` section and `display_name` key to
source section examples (as shown above).
2. **`notify.py` — file watcher**
- Polls `recordings/status.json` every 30 s.
- Tracks which files were in `active` on the previous poll.
- When a file disappears from `active` it was just closed → queues it for
analysis.
- Skips files with extensions that cannot be analysed (anything other than
`.wav` / `.flac`).
3. **`notify.py` — analysis + filter**
- Imports `analyze_wav` / `analyze_flac` from `web.py`.
- Applies `loudness_threshold` from `[notify]` config.
- Filters resulting sections to those with duration ≥ `min_section_duration`.
- Counts filtered sections against `min_sections` threshold.
4. **`notify.py` — NTFY HTTP POST**
- Plain `urllib` POST to `ntfy_url` (no extra dependencies).
- Sets `Title` and message body as described above.
- Logs success / failure to stdout.
#### Phase 2 — Cadence modes
5. **Immediate mode with debounce**
- Fires right after the file closes and analysis passes.
- Persists last-notification timestamp per source to a small
`notify_state.json` in the recordings directory.
- Suppresses sending if last notification for that source was within
`debounce_minutes`.
6. **Daily digest mode**
- Appends qualifying events to `notify_log.jsonl` in the recordings
directory (one JSON line per event: timestamp, source, filename, sections,
peak RMS).
- On each poll checks whether `daily_time` has passed today and no digest
has been sent yet (tracked in `notify_state.json`).
- Reads all undigested entries from `notify_log.jsonl`, groups by
`display_name`, sends one notification per source with notable activity.
- Marks entries as digested.
7. **Both mode**
- Immediate path: only fires when peak RMS exceeds a second, higher
threshold (`alarm_threshold`, default `0.3`; add to `[notify]` config).
- Daily digest path: fires for everything that passes `min_sections`.
#### Phase 3 — Integration
8. **Docker** — optional `notify` service in `docker-compose.yml`:
```yaml
notify:
build: .
command: python notify.py
volumes:
- ./recordings:/app/recordings
- ./config.ini:/app/config.ini:ro
restart: unless-stopped
```
9. **README** — new section documenting `notify.py` usage, config keys, and
Docker setup.
---
### Open questions (decide before implementing)
- **Log rotation**: `notify_log.jsonl` grows indefinitely. Options: cap at N
days (configurable), cap at N MB, or leave cleanup to the user. No decision
made yet.
- **Multiple NTFY topics per source**: current design uses one global topic.
If per-source topics are ever needed, `ntfy_url` could be moved to the source
section and override the global one.
- **FFT / frequency analysis** (future): distinguishing thunder (low rumble,
50200 Hz) from speech (3003000 Hz) from vehicles would reduce false
positives further. Deferred — requires `numpy` and adds meaningful complexity.
+4 -12
View File
@@ -17,13 +17,10 @@ output_directory = recordings
# Duration in minutes after which to split into a new file # Duration in minutes after which to split into a new file
split_minutes = 60 split_minutes = 60
# Filename pattern with strftime format codes # Recording filenames are fixed as <YYYYMMDD>_<HHMMSS>.<ext> (the start time,
# Examples: # e.g. 20241216_143000.flac). This is not configurable: the web UI parses the
# %Y%m%d_%H%M%S -> 20241216_143000.ext # start time back out of the filename to show the date and to name cut clips
# recording_%Y-%m-%d_%H%M -> recording_2024-12-16_1430.ext # with real wall-clock times.
# %Y/%m/%d/audio_%H%M%S -> 2024/12/16/audio_143000.ext (creates subdirs)
# Common codes: %Y=year, %m=month, %d=day, %H=hour, %M=minute, %S=second
filename_pattern = %Y%m%d_%H%M%S
# Maximum number of connection/recording retry attempts before giving up # Maximum number of connection/recording retry attempts before giving up
max_retries = 10 max_retries = 10
@@ -58,7 +55,6 @@ format = auto
# Override general settings for this source (optional): # Override general settings for this source (optional):
# output_directory = recordings/streams # output_directory = recordings/streams
# split_minutes = 30 # split_minutes = 30
# filename_pattern = mystream_%Y%m%d_%H%M%S
# ============================================================================= # =============================================================================
@@ -93,7 +89,6 @@ format = auto
# # Override general settings for this source (optional): # # Override general settings for this source (optional):
# # output_directory = recordings/soundcard # # output_directory = recordings/soundcard
# # split_minutes = 60 # # split_minutes = 60
# # filename_pattern = soundcard_%Y%m%d_%H%M%S
# ============================================================================= # =============================================================================
@@ -105,13 +100,11 @@ format = auto
# type = stream # type = stream
# url = http://radio1.example.com:8000/live # url = http://radio1.example.com:8000/live
# format = auto # format = auto
# filename_pattern = radio1_%Y%m%d_%H%M%S
# #
# [radio_station_2] # [radio_station_2]
# type = stream # type = stream
# url = http://radio2.example.com:8000/live # url = http://radio2.example.com:8000/live
# format = auto # format = auto
# filename_pattern = radio2_%Y%m%d_%H%M%S
# #
# [system_audio] # [system_audio]
# type = soundcard # type = soundcard
@@ -119,7 +112,6 @@ format = auto
# sample_rate = 48000 # sample_rate = 48000
# channels = 2 # channels = 2
# format = flac # format = flac
# filename_pattern = system_%Y%m%d_%H%M%S
# ============================================================================= # =============================================================================
+2 -1
View File
@@ -16,7 +16,8 @@ services:
build: . build: .
volumes: volumes:
- ./recordings:/recordings:ro - ./recordings:/recordings:ro
- ./recordings/analyses:/recordings/analyses
ports: ports:
- "8080:8080" - "8050:8080"
restart: unless-stopped restart: unless-stopped
command: ["python", "web.py", "--dir", "/recordings"] command: ["python", "web.py", "--dir", "/recordings"]
+27 -74
View File
@@ -10,7 +10,6 @@ import os
import sys import sys
import time import time
import wave import wave
import struct
import signal import signal
import logging import logging
import threading import threading
@@ -18,7 +17,7 @@ import configparser
import subprocess import subprocess
import shutil import shutil
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List, Callable from typing import Optional, Dict, Any, List, Callable
@@ -43,6 +42,13 @@ except ImportError:
SOUNDFILE_AVAILABLE = False SOUNDFILE_AVAILABLE = False
# Fixed recording-filename timestamp format. This is the recording's *start*
# time and is the single source of truth for the clock: web.py parses it back
# out to derive the displayed date and to name cut clips with real wall-clock
# times. It is intentionally not configurable — both files must agree on it.
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
# ============================================================================= # =============================================================================
# Audio Device & Backend System # Audio Device & Backend System
# ============================================================================= # =============================================================================
@@ -54,11 +60,9 @@ class AudioDevice:
name: str # Human-readable name name: str # Human-readable name
channels: int # Max input channels channels: int # Max input channels
sample_rate: int # Default sample rate sample_rate: int # Default sample rate
backend: str # Backend name (pulseaudio, pipewire, portaudio) backend: str # Backend name ('alsa')
is_default: bool = False # Is system default is_default: bool = False # Is system default
is_monitor: bool = False # Is a monitor/loopback source is_monitor: bool = False # Is a monitor/loopback source
description: str = "" # Extended description
extra: Dict[str, Any] = field(default_factory=dict)
def __str__(self): def __str__(self):
flags = [] flags = []
@@ -74,7 +78,6 @@ class AudioBackend(ABC):
"""Abstract base for audio capture backends.""" """Abstract base for audio capture backends."""
name: str = "base" name: str = "base"
priority: int = 0 # Higher = preferred
@classmethod @classmethod
@abstractmethod @abstractmethod
@@ -99,7 +102,6 @@ class ALSABackend(AudioBackend):
"""ALSA backend using arecord (raw PCM output, no sound server required).""" """ALSA backend using arecord (raw PCM output, no sound server required)."""
name = "alsa" name = "alsa"
priority = 5 # Lowest priority — direct hardware access, use when no sound server runs
@classmethod @classmethod
def is_available(cls) -> bool: def is_available(cls) -> bool:
@@ -251,19 +253,12 @@ class AudioSystem:
def get_backend(self, name: str) -> Optional[AudioBackend]: def get_backend(self, name: str) -> Optional[AudioBackend]:
return self._backends.get(name) return self._backends.get(name)
def get_preferred_backend(self) -> Optional[AudioBackend]:
"""Get the highest priority available backend."""
if not self._backends:
return None
return max(self._backends.values(), key=lambda b: b.__class__.priority)
def list_all_devices(self) -> List[AudioDevice]: def list_all_devices(self) -> List[AudioDevice]:
"""List devices from all available backends.""" """List devices from all available backends."""
all_devices = [] all_devices = []
seen_names = set() seen_names = set()
# Get devices from backends in priority order for cls in self._backend_classes:
for cls in sorted(self._backend_classes, key=lambda c: -c.priority):
if cls.name in self._backends: if cls.name in self._backends:
for dev in cls.list_devices(): for dev in cls.list_devices():
# Deduplicate by name (same device may appear in multiple backends) # Deduplicate by name (same device may appear in multiple backends)
@@ -341,7 +336,6 @@ class BaseRecorder(ABC):
# Common settings # Common settings
self.split_duration = config.get('split_minutes', 60) self.split_duration = config.get('split_minutes', 60)
self.output_dir = config.get('output_directory', 'recordings') self.output_dir = config.get('output_directory', 'recordings')
self.filename_pattern = config.get('filename_pattern', '%Y%m%d_%H%M%S')
self.max_retries = config.get('max_retries', 10) self.max_retries = config.get('max_retries', 10)
self.retry_delay = config.get('retry_delay_seconds', 5) self.retry_delay = config.get('retry_delay_seconds', 5)
self.file_format = config.get('format', 'auto') self.file_format = config.get('format', 'auto')
@@ -355,9 +349,9 @@ class BaseRecorder(ABC):
return next_split.replace(second=0, microsecond=0) return next_split.replace(second=0, microsecond=0)
def generate_filename(self, ext: str) -> str: def generate_filename(self, ext: str) -> str:
"""Generate filename from pattern with strftime substitution.""" """Generate filename from the fixed start-time format (see FILENAME_FORMAT)."""
now = self._clock() now = self._clock()
filename = now.strftime(self.filename_pattern) + f".{ext}" filename = now.strftime(FILENAME_FORMAT) + f".{ext}"
full_path = os.path.join(self.output_dir, filename) full_path = os.path.join(self.output_dir, filename)
Path(full_path).parent.mkdir(parents=True, exist_ok=True) Path(full_path).parent.mkdir(parents=True, exist_ok=True)
return full_path return full_path
@@ -726,23 +720,17 @@ class SoundcardRecorder(BaseRecorder):
if self.current_file is None: if self.current_file is None:
return return
# Swap the buffer under the lock, write outside it — disk writes (and
# FLAC encoding) must not block the capture callback.
with self.buffer_lock: with self.buffer_lock:
if self.audio_buffer: buffered, self.audio_buffer = self.audio_buffer, []
for data in self.audio_buffer: for data in buffered:
self.current_file.write(data) self.current_file.write(data)
self.audio_buffer.clear()
def close_current_file(self): def close_current_file(self):
"""Close current recording file.""" """Flush any buffered audio, then close the current recording file."""
self._flush_buffer_to_file() self._flush_buffer_to_file()
if self.current_file: super().close_current_file()
try:
self.current_file.close()
self.logger.info(f"[{self.name}] Closed: {self.current_filename}")
except Exception as e:
self.logger.error(f"[{self.name}] Error closing file: {e}")
self.current_file = None
self.current_filename = None
def record(self): def record(self):
"""Main recording loop.""" """Main recording loop."""
@@ -824,7 +812,6 @@ class RecorderManager:
general = { general = {
'output_directory': config.get('general', 'output_directory', fallback='recordings'), 'output_directory': config.get('general', 'output_directory', fallback='recordings'),
'split_minutes': config.getint('general', 'split_minutes', fallback=60), 'split_minutes': config.getint('general', 'split_minutes', fallback=60),
'filename_pattern': config.get('general', 'filename_pattern', fallback='%Y%m%d_%H%M%S', raw=True),
'max_retries': config.getint('general', 'max_retries', fallback=10), 'max_retries': config.getint('general', 'max_retries', fallback=10),
'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5), 'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5),
'log_level': config.get('general', 'log_level', fallback='INFO').upper(), 'log_level': config.get('general', 'log_level', fallback='INFO').upper(),
@@ -990,22 +977,15 @@ def list_audio_devices():
print(" ISR Audio Device Discovery") print(" ISR Audio Device Discovery")
print("=" * 70) print("=" * 70)
# Check available backends if not ALSABackend.is_available():
available_backends = []
if ALSABackend.is_available():
available_backends.append(('alsa', 'ALSA (arecord)', 5))
if not available_backends:
print("\n No audio backends available!") print("\n No audio backends available!")
print("\n Install one of:") print("\n Install ALSA utilities:")
print(" sudo apt install alsa-utils (ALSA, always available on Linux)") print(" sudo apt install alsa-utils")
print() print()
return return
print("\n Available Backends:") print("\n Available Backends:")
for name, label, priority in sorted(available_backends, key=lambda x: -x[2]): print(" - ALSA (arecord)")
marker = " (preferred)" if priority == max(b[2] for b in available_backends) else ""
print(f" - {label}{marker}")
# Initialize audio system and list devices # Initialize audio system and list devices
audio_system = AudioSystem(logger) audio_system = AudioSystem(logger)
@@ -1016,30 +996,10 @@ def list_audio_devices():
print() print()
return return
# Group by type
monitors = [d for d in devices if d.is_monitor]
inputs = [d for d in devices if not d.is_monitor]
if inputs:
print("\n Input Devices:") print("\n Input Devices:")
print(" " + "-" * 68) print(" " + "-" * 68)
for dev in inputs: for dev in devices:
flags = [] flag_str = " [DEFAULT]" if dev.is_default else ""
if dev.is_default:
flags.append("DEFAULT")
flag_str = f" [{', '.join(flags)}]" if flags else ""
print(f"\n {dev.name}{flag_str}")
print(f" ID: {dev.id} | Backend: {dev.backend}")
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz")
if monitors:
print("\n Monitor/Loopback Sources:")
print(" " + "-" * 68)
for dev in monitors:
flags = ["MONITOR"]
if dev.is_default:
flags.append("DEFAULT")
flag_str = f" [{', '.join(flags)}]"
print(f"\n {dev.name}{flag_str}") print(f"\n {dev.name}{flag_str}")
print(f" ID: {dev.id} | Backend: {dev.backend}") print(f" ID: {dev.id} | Backend: {dev.backend}")
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz") print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz")
@@ -1048,10 +1008,9 @@ def list_audio_devices():
print(" Configuration Examples:") print(" Configuration Examples:")
print("-" * 70) print("-" * 70)
print(" device = default # Use system default input") print(" device = default # Use system default input")
print(" device = monitor # Use first monitor/loopback source")
print(" device = <name> # Match by partial name") print(" device = <name> # Match by partial name")
print(" device = <id> # Use exact backend ID") print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
print(" backend = pipewire # Force specific backend") print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g. shared_mic")
print("=" * 70 + "\n") print("=" * 70 + "\n")
@@ -1061,17 +1020,11 @@ def main():
list_audio_devices() list_audio_devices()
return return
# Get config file # Get config file (RecorderManager exits with a usage message if it's missing)
config_file = 'config.ini' config_file = 'config.ini'
if len(sys.argv) > 1: if len(sys.argv) > 1:
config_file = sys.argv[1] config_file = sys.argv[1]
if not os.path.exists(config_file):
print(f"Error: Configuration file '{config_file}' not found!")
print("Usage: python ISR.py [config.ini]")
print(" python ISR.py --list-devices")
sys.exit(1)
# Docker sends SIGTERM before SIGKILL — treat it the same as Ctrl+C # Docker sends SIGTERM before SIGKILL — treat it the same as Ctrl+C
def _sigterm(sig, frame): def _sigterm(sig, frame):
raise KeyboardInterrupt() raise KeyboardInterrupt()
+6 -70
View File
@@ -4,15 +4,13 @@ Tests for isr.py
Run with: pytest tests/ Run with: pytest tests/
""" """
import io
import logging import logging
import struct import struct
import wave import wave
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import List from typing import List
from unittest.mock import MagicMock, patch, call from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -144,7 +142,6 @@ class TestGetNextSplitTime:
r._clock = fixed_clock(now) r._clock = fixed_clock(now)
r.split_duration = split_minutes r.split_duration = split_minutes
r.output_dir = cfg["output_directory"] r.output_dir = cfg["output_directory"]
r.filename_pattern = "%Y%m%d_%H%M%S"
r.max_retries = 3 r.max_retries = 3
r.retry_delay = 1 r.retry_delay = 1
r.file_format = "auto" r.file_format = "auto"
@@ -186,7 +183,7 @@ class TestGetNextSplitTime:
class TestGenerateFilename: class TestGenerateFilename:
"""Tests for BaseRecorder.generate_filename().""" """Tests for BaseRecorder.generate_filename()."""
def _recorder(self, pattern: str, now: datetime, output_dir: str) -> isr.BaseRecorder: def _recorder(self, now: datetime, output_dir: str) -> isr.BaseRecorder:
class _Rec(isr.BaseRecorder): class _Rec(isr.BaseRecorder):
def record(self): pass def record(self): pass
@@ -200,28 +197,21 @@ class TestGenerateFilename:
r._clock = fixed_clock(now) r._clock = fixed_clock(now)
r.split_duration = 60 r.split_duration = 60
r.output_dir = output_dir r.output_dir = output_dir
r.filename_pattern = pattern
r.max_retries = 3 r.max_retries = 3
r.retry_delay = 1 r.retry_delay = 1
r.file_format = "auto" r.file_format = "auto"
return r return r
def test_basic_pattern(self, tmp_path): def test_fixed_format(self, tmp_path):
# Filenames are always <YYYYMMDD>_<HHMMSS>.<ext> — the recording start.
now = datetime(2024, 12, 25, 14, 30, 0) now = datetime(2024, 12, 25, 14, 30, 0)
r = self._recorder("%Y%m%d_%H%M%S", now, str(tmp_path)) r = self._recorder(now, str(tmp_path))
name = r.generate_filename("mp3") name = r.generate_filename("mp3")
assert name.endswith("20241225_143000.mp3") assert name.endswith("20241225_143000.mp3")
def test_subdirectory_created(self, tmp_path):
now = datetime(2024, 12, 25, 14, 30, 0)
r = self._recorder("%Y/%m/%d/rec_%H%M%S", now, str(tmp_path))
name = r.generate_filename("ogg")
parent = Path(name).parent
assert parent.exists()
def test_output_dir_prefix(self, tmp_path): def test_output_dir_prefix(self, tmp_path):
now = datetime(2024, 1, 1, 0, 0, 0) now = datetime(2024, 1, 1, 0, 0, 0)
r = self._recorder("%Y%m%d", now, str(tmp_path)) r = self._recorder(now, str(tmp_path))
name = r.generate_filename("wav") name = r.generate_filename("wav")
assert name.startswith(str(tmp_path)) assert name.startswith(str(tmp_path))
@@ -238,7 +228,6 @@ class TestDetectFormat:
"url": "http://example.com/stream", "url": "http://example.com/stream",
"output_directory": "/tmp", "output_directory": "/tmp",
"split_minutes": 60, "split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1, "max_retries": 1,
"retry_delay_seconds": 0, "retry_delay_seconds": 0,
"format": "auto", "format": "auto",
@@ -254,7 +243,6 @@ class TestDetectFormat:
r._clock = datetime.now r._clock = datetime.now
r.split_duration = 60 r.split_duration = 60
r.output_dir = "/tmp" r.output_dir = "/tmp"
r.filename_pattern = "%Y%m%d_%H%M%S"
r.max_retries = 1 r.max_retries = 1
r.retry_delay = 0 r.retry_delay = 0
r.file_format = "auto" r.file_format = "auto"
@@ -448,7 +436,6 @@ class TestRecorderManagerLoadConfig:
[general] [general]
output_directory = {str(tmp_path / "recordings")} output_directory = {str(tmp_path / "recordings")}
split_minutes = 30 split_minutes = 30
filename_pattern = test_%Y%m%d
max_retries = 3 max_retries = 3
retry_delay_seconds = 2 retry_delay_seconds = 2
log_level = WARNING log_level = WARNING
@@ -493,7 +480,6 @@ format = ogg
[general] [general]
output_directory = {str(tmp_path / "recordings")} output_directory = {str(tmp_path / "recordings")}
split_minutes = 60 split_minutes = 60
filename_pattern = %Y%m%d_%H%M%S
max_retries = 10 max_retries = 10
retry_delay_seconds = 5 retry_delay_seconds = 5
log_level = WARNING log_level = WARNING
@@ -503,7 +489,6 @@ log_file = {log_file}
type = stream type = stream
url = http://example.com/stream url = http://example.com/stream
split_minutes = 15 split_minutes = 15
filename_pattern = custom_%Y%m%d
""" """
cfg_path = tmp_path / "config.ini" cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text) cfg_path.write_text(config_text)
@@ -514,7 +499,6 @@ filename_pattern = custom_%Y%m%d
rec = mgr.recorders[0] rec = mgr.recorders[0]
assert rec.split_duration == 15 assert rec.split_duration == 15
assert rec.filename_pattern == "custom_%Y%m%d"
def test_unknown_type_is_skipped(self, tmp_path): def test_unknown_type_is_skipped(self, tmp_path):
log_file = str(tmp_path / "test.log") log_file = str(tmp_path / "test.log")
@@ -560,7 +544,6 @@ class TestStreamRecorderRecord:
"url": "http://example.com/stream", "url": "http://example.com/stream",
"output_directory": "", # overridden per-test with tmp_path "output_directory": "", # overridden per-test with tmp_path
"split_minutes": 60, "split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 2, "max_retries": 2,
"retry_delay_seconds": 0, "retry_delay_seconds": 0,
"format": fmt, "format": fmt,
@@ -571,50 +554,6 @@ class TestStreamRecorderRecord:
) )
return r return r
def test_mp3_chunks_written_to_file(self, tmp_path):
chunks = [b"A" * 512, b"B" * 512, b"C" * 512]
rec = self._recorder(fmt="mp3")
rec.output_dir = str(tmp_path)
mock_resp = MagicMock()
mock_resp.headers = {"Content-Type": "audio/mpeg"}
mock_resp.iter_content.return_value = iter(chunks)
def _stop_after_connect(*args, **kwargs):
rec.running = True
return mock_resp
with patch.object(rec, "connect_stream", side_effect=[mock_resp, None]):
# connect_stream returns the mocked response on the first call;
# we stop the loop via a side-effectful iter_content
original_iter = mock_resp.iter_content.return_value
def _chunks_then_stop(chunk_size=8192):
for c in chunks:
yield c
rec.running = False # stop the outer while loop
mock_resp.iter_content.side_effect = _chunks_then_stop
mock_resp.headers = {"Content-Type": "audio/mpeg"}
rec.detected_format = "mp3"
rec.running = True
rec.header_capture_complete = True
rec.stream_headers = None
# Open a file manually so we can verify writes
filename = rec.generate_filename("mp3")
rec.current_file = open(filename, "wb")
rec.current_filename = filename
# Simulate one inner loop iteration
for chunk in _chunks_then_stop():
if chunk:
rec.current_file.write(chunk)
rec.close_current_file()
written = Path(filename).read_bytes()
assert written == b"A" * 512 + b"B" * 512 + b"C" * 512
def test_connection_failure_retries(self, tmp_path): def test_connection_failure_retries(self, tmp_path):
rec = self._recorder() rec = self._recorder()
rec.output_dir = str(tmp_path) rec.output_dir = str(tmp_path)
@@ -656,7 +595,6 @@ class TestSoundcardRecorder:
"format": "wav", "format": "wav",
"output_directory": str(tmp_path), "output_directory": str(tmp_path),
"split_minutes": 60, "split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1, "max_retries": 1,
"retry_delay_seconds": 0, "retry_delay_seconds": 0,
} }
@@ -685,7 +623,6 @@ class TestSoundcardRecorder:
"format": "flac", "format": "flac",
"output_directory": str(tmp_path), "output_directory": str(tmp_path),
"split_minutes": 60, "split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1, "max_retries": 1,
"retry_delay_seconds": 0, "retry_delay_seconds": 0,
} }
@@ -708,7 +645,6 @@ class TestSoundcardRecorder:
"format": "flac", "format": "flac",
"output_directory": str(tmp_path), "output_directory": str(tmp_path),
"split_minutes": 60, "split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1, "max_retries": 1,
"retry_delay_seconds": 0, "retry_delay_seconds": 0,
} }
+162
View File
@@ -0,0 +1,162 @@
"""Tests for the adaptive loud-section detector in web.py."""
import math
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
def _run(rms, margin_db=12.0, min_gap=2.0, min_duration=0.5):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
def test_burst_above_quiet_floor_is_detected():
rms = [0.002] * 1200 # 2 min of quiet ambience (54 dBFS)
rms[600:610] = [0.05] * 10 # 1 s burst at 26 dBFS (+28 dB)
sections = _run(rms)
assert len(sections) == 1
s = sections[0]
assert s['start'] == 60.0
assert 60.5 <= s['end'] <= 62.0
assert 26.0 <= s['score'] <= 30.0
def test_slow_ambience_swell_is_not_detected():
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
# The old fixed threshold would have flagged the entire loud half.
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # 54 → 34 dB
down = list(reversed(up))
assert _run(up + down) == []
def test_burst_still_detected_on_loud_ambience():
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
rms = [0.02] * 1200
rms[600:610] = [0.5] * 10
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_rms_floor_suppresses_blips_in_digital_silence():
rms = [0.000001] * 1200
rms[600:605] = [0.004] * 5 # 48 dBFS: audible blip, but below MIN_RMS+12dB
assert _run(rms) == []
rms[600:605] = [0.05] * 5 # 26 dBFS clearly clears the clamped floor
assert len(_run(rms)) == 1
def test_min_gap_merges_nearby_bursts():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
rms[900:905] = [0.05] * 5 # 28 s away → separate section
sections = _run(rms)
assert len(sections) == 2
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.5
assert sections[1]['start'] == 90.0
def test_min_duration_drops_subsecond_blips():
# Isolated single-window pops (clicks, single raindrops) spaced wider than
# min_gap must not each become their own section — this is what used to
# produce thousands of zero-length sections per day.
rms = [0.002] * 1200
for i in range(600, 660, 30): # 0.1 s blips, 3 s apart (> min_gap)
rms[i] = 0.05
assert _run(rms) == []
# With the filter disabled they are all reported
assert len(_run(rms, min_duration=0.0)) == 2
def test_min_duration_keeps_sections_at_or_above_it():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5 # exactly 0.5 s
sections = _run(rms, min_duration=0.5)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_duration_applies_after_gap_merging():
# Two sub-min_duration blips within min_gap merge into one section whose
# loud span exceeds min_duration — the merged section must survive.
rms = [0.002] * 1200
rms[600] = 0.05
rms[610] = 0.05 # 1 s apart < 2 s min_gap → merged, 1.1 s span
sections = _run(rms, min_duration=1.0)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.0
def test_slow_swell_scores_near_zero_sharp_burst_scores_high():
# A ~10 s swell rises faster than the 30 s floor blocks can track, so it
# still flags — but its score must collapse (onset cap) so it ranks at the
# bottom, while a sharp burst with the same peak keeps its full score.
rms = [0.002] * 1200
for k in range(80): # rise 54 → 28 dB over 8 s
rms[400 + k] = 0.002 * 10 ** (26 * (k / 80) / 20)
rms[480:500] = [0.04] * 20 # hold 2 s at 28 dB
for k in range(80): # fall back over 8 s
rms[500 + k] = 0.04 * 10 ** (-26 * (k / 80) / 20)
rms[900:910] = [0.04] * 10 # sharp 1 s burst, same peak
sections = _run(rms)
assert len(sections) == 2
swell, burst = sections
assert swell['start'] < 50.0 < 90.0 <= burst['start']
assert burst['score'] >= 24.0 # ≈ full 26 dB prominence
assert swell['score'] <= 5.0 # capped by its slow onset
assert swell['score'] < burst['score']
def test_burst_at_file_start_keeps_full_score():
# No pre-event history to measure a rise against: the onset cap falls back
# to prominence above the floor, so events cut off by a file split are not
# punished as swells.
rms = [0.05] * 10 + [0.002] * 1190
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 0.0
assert sections[0]['score'] >= 24.0
def test_noise_floor_tracks_blocks_and_ignores_short_events():
quiet_db = 20 * math.log10(0.002)
db = [quiet_db] * 1200
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
floor = _noise_floor_db(db, WINDOW_DUR)
assert len(floor) == len(db)
assert all(abs(f - quiet_db) < 1.0 for f in floor)
# ---------------------------------------------------------------------------
# Filename parsing / cut naming
# ---------------------------------------------------------------------------
def test_recording_start_parses_standard_name():
from datetime import datetime
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
def test_recording_start_rejects_nonstandard_name():
assert _recording_start("radio1_20260523") is None
assert _recording_start("notes") is None
def test_cut_filename_uses_wall_clock_span():
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
assert name == "20260523_22-31-30_22-32-30.flac"
def test_cut_filename_rolls_over_the_hour():
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
assert name == "20260523_22-59-50_23-01-01.wav"
def test_cut_filename_falls_back_for_nonstandard_name():
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
assert name == "mixtape_cut_740s-750s.mp3"
+648 -1055
View File
File diff suppressed because it is too large Load Diff
+1214
View File
File diff suppressed because it is too large Load Diff