Compare commits
11
Commits
24569efe20
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a19515c27 | ||
|
|
c91264c253 | ||
|
|
138f6db0a5 | ||
|
|
0e7ea82da6 | ||
|
|
625ee0e7db | ||
|
|
a836948882 | ||
|
|
b661e166ed | ||
|
|
9bb1229aa9 | ||
|
|
1541110ffb | ||
|
|
4aa7f7253e | ||
|
|
46af41b12d |
@@ -7,3 +7,4 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.claude/
|
.claude/
|
||||||
nul
|
nul
|
||||||
|
.claude-notes/
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ Guidance for Claude Code when working in this repository.
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **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 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.
|
||||||
- Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector).
|
- Run `python -m pytest tests/` after changing `isr.py`, `web.py`, `isrweb/` or `webui/js/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits |
|
| `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 |
|
| `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui/` lookup work |
|
||||||
| `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 |
|
| `isrweb/loudness.py` | The adaptive loud-section detector. Pure computation, imports nothing from the app — future spectral/VAD work lands here and never touches HTTP |
|
||||||
|
| `isrweb/audiofile.py` | What an audio file is and what its name means: the filename clock, FLAC/WAV header parsing, live-header patching. Also a leaf |
|
||||||
|
| `webui/` | Browser UI: `index.html`, `style.css`, ES modules in `js/` (no build step). Served by `web.py` (`/` and `/ui/<path>`) — 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`) |
|
| `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 |
|
| `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard |
|
||||||
|
|
||||||
@@ -30,23 +32,40 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
|
|||||||
|
|
||||||
## Code map
|
## Code map
|
||||||
|
|
||||||
`web.py`:
|
**Import direction is strictly `web.py → {isrweb.loudness, isrweb.audiofile}`.** The two leaves never import each other or `web.py`; keep it that way, it is what lets the detector be tested without an HTTP server. Each module carries its own optional numpy/soundfile try-import — that boilerplate cannot meaningfully diverge, and sharing it would stop `isr.py` being a standalone file.
|
||||||
- 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.
|
|
||||||
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice, `_wav_header()` builds the 44-byte PCM header.
|
|
||||||
- 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.
|
|
||||||
- Live headers: `_live_wav_header()`, `_live_flac_header()` (+ `_flac_frame_samples()`, CRC-8 verified).
|
|
||||||
- Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard).
|
|
||||||
|
|
||||||
`webui.html` (one `<script>` block):
|
`isrweb/loudness.py`:
|
||||||
- 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. **Queue entries carry the filename, never a row index:** `_idx` is reassigned positionally on every `load()`, and `pollStatus()` reloads whenever a recording starts/stops/rolls over, so a stored index goes stale mid-review (a past bug had "Open in file"/cut fields target the row one off after a split rollover). Resolve at use time via `fileIdxByName()`. **`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.
|
- Detection: `_compute_rms_windows_wav()` / `analyze_flac()` produce 100 ms RMS windows (at 48 kHz — `WINDOW_SAMPLES` is a fixed sample count, so windows are wider on lower-rate files; `analyze_wav()` rejects anything that is not 16-bit, which the RMS decoder assumes) → `_noise_floor_db()` estimates the rolling floor → `_loud_sections()` emits scored sections → `_package_result()` shapes the `/api/analyze` payload.
|
||||||
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration 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.
|
|
||||||
- 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.
|
`isrweb/audiofile.py`:
|
||||||
- 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).
|
- 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.
|
||||||
|
- Live headers: `_live_wav_header()`, `_live_flac_header()` (+ `_flac_frame_samples()`, CRC-8 verified). Plus `_wav_header()` (44-byte PCM header for clips), `_get_audio_duration()`, `MIME_TYPES`, `AUDIO_EXTENSIONS`.
|
||||||
|
|
||||||
|
`web.py`:
|
||||||
|
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice.
|
||||||
|
- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard). `_ui()` serves `webui/` (same containment check, html/css/js only, read per request so UI edits need no restart).
|
||||||
|
|
||||||
|
`webui/js/` — native ES modules, one concern each. **Each module imports only from modules earlier in this list:** `util` → `api` → `store` → `settings` → `player` → `clips` → `analysis` → `day` → `list` → `nav` → `main`. Keep it acyclic; when an earlier module needs a later one it goes through the DOM (`openClipInFile()` clicks the day toggle) rather than an import. Mutable shared state is a module-level `export let` reassigned only inside its own module (importers get the live binding).
|
||||||
|
- `util.js`: formatting (`fmtDur`, `fmtClock`, `fmtHM`, `fmtDurWords`, `dayLabel()` → "Today, Saturday 17 October"), `announce()` (the single `#sr-announce` live region), `chipToggle()` (collapses chip lists > 12 behind an `aria-expanded` button), `remember` (try/catch localStorage for per-browser conveniences only: pre-roll, auto-advance, settings open).
|
||||||
|
- `api.js`: every URL. `store.js`: `allFiles`, `sectionMap`, `fileSpan(f)` (the spoken name of a recording, "22:00 to 23:00", from the filename clock). **`_idx` is stable per filename for the whole session** (assigned on first sight, never reused), so DOM ids (`row-`, `pbtn-`, `aud-`, `wave-`, …) survive reloads. Queue entries still carry the filename, never an index — the file can disappear; resolve at use time via `fileIdxByName()`.
|
||||||
|
- `settings.js`: `analysisParams()` is the only reader of the three analysis inputs — no hardcoded client-side defaults, so web.py's constants stay the single source of truth via `/api/config` (and they are deliberately *not* remembered in localStorage); `hlParams()` is just its values joined. `cachedParamsMatch()` (autoload guard), `getPreroll()`, `advanceMode()`.
|
||||||
|
- `player.js`: per-row full-file player and cut fields (`togglePlayer`, `seekToSection`, `setCutFields`, `toggleCut`, `activePlayerIdx`). Row layout (user's choice): Time (row header) | Length | Loudness | Player | Options | File — one table row per recording. The `<audio>` opens *inside* the Player cell next to the Play/Hide button (there is no separate player row any more); the Options cell (Download, Cut, Delete) is always visible, and Cut is an `aria-expanded` toggle for the start/end fields inside it.
|
||||||
|
- `clips.js` — clip review: `playClip()`, `hideClipBar()`, `armQueue()` (point the queue at a list, reset both cursors), `playFrom()` (explicit jump), `stepClip()`, `openClipInFile()`. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`); `absStart` (epoch s) comes from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback; the filename/offset lives in the tooltip. **`playClip()` builds one `text` string used for `label.textContent`, the `announce()` message and the Media Session title** — 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 "Next loudest" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour leaves the loudness walk's place intact and the next U/I resumes the ranking. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `armQueue`, `playFrom`), so the next U/I re-anchors on `clipCursor` else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (suffix "by loudness"), else `i` ("by time") — the suffix tells the blind user which dimension the count is in. Both U/I walks (`stepClip` and the in-player branch of `nav.navigate()`) share `nextInOrder(order, pos, dir, jump)`; they must not re-inline it. Closing the bar (× or Escape) returns focus to where it was when the bar opened.
|
||||||
|
- `analysis.js`: `fetchAnalysis()` (session cache; updates `f.cached_analysis` client-side so the analysed marker survives re-renders), `analyse()` (result cell: meta line → chips with wall-clock labels → Re-analyse), `analyseButton()`, `autoloadDayAnalyses()`.
|
||||||
|
- `day.js` — day review: `toggleHighlights()` (the Highlights button is a collapse/expand toggle; a built panel is kept and re-armed from `dayHlSections`, keyed by `panel.dataset.loaded = hlParams()` so changed params force a re-run), `dayHighlights()` builds the chronological cross-file section list, `armDayQueue()` is the single place that points the queue at a day, `releaseDay()` on day collapse. **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 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. 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.
|
||||||
|
- `list.js`: `renderFiles()` is **incremental** — each day section carries `data-sig` (`daySignature()`: params + names + recording flags + durations) and is rebuilt only when that changes, so a rollover touches today alone and the rest of the page (and the screen reader's position) stays put; focus inside a rebuilt day is restored by id. `pollStatus()` still reloads only when no audio is playing. `deleteFile()` moves focus to the next row's Play button. `load({speak})`/`applyFilters({speak})`: only user-initiated actions announce; background reloads are silent.
|
||||||
|
- `nav.js`: the single document-level J/K/U/I/O `keydown` listener and `navigate()` — clip queue first, in-player `currentTime` stepping as the fallback; O = `openClipInFile()`. Media Session previous/next track call `navigate(∓1)` (AirPods, lock screen, keyboard media keys). J/K (and Prev/Next) always step in time order; U/I walk `scoreOrder()` with no top-N cutoff (a `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance setting). Auto-advance is the `input[name="clip-adv"]` radio (`off` / `all` = next in time / `hl` = next loudest).
|
||||||
|
|
||||||
|
Screen-reader rules learned the hard way (apply to any markup change):
|
||||||
|
- **`aria-label` on a button replaces its visible content** — status text rendered inside a labelled button is never heard. The day Highlights button therefore has *no* aria-label: its content (`Highlights` + sr-only "for <day>" + the `#dayhls-` " · analysed" / progress suffix) is its name.
|
||||||
|
- The accessible text of a button built as `aria-hidden arrow span + text` must not start the text node with a space (reads as an indent) — keep separator spaces inside the aria-hidden span.
|
||||||
|
- Never disable or replace the focused element: busy buttons get `aria-disabled` + a guard, and code that replaces a container moves focus to its replacement (`analyse()` → first chip).
|
||||||
|
- `[hidden]{display:none !important}` in style.css is load-bearing: author `display` rules (flex chips, inline-flex REC badge, flex clip bar) otherwise beat the UA `[hidden]` rule — the REC badge once showed on every row that way.
|
||||||
|
|
||||||
## Verifying changes
|
## Verifying changes
|
||||||
|
|
||||||
- `python -m pytest tests/` covers the recorder (`test_isr.py`) and the detector (`test_web.py`).
|
- `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.
|
- There is no JS toolchain and no `node` on the dev box. `test_web.py` checks every relative module import resolves. After editing `webui/`, cross-check every `$('x')`/`getElementById('x')` against an `id="x"` declaration, then smoke-test in a real browser: headless Edge is installed (`C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe --headless=new --virtual-time-budget=60000 --dump-dom <url>`). Serve `web.py` over generated WAVs with `WEBUI_DIR` pointed at a scratch copy of `webui/` plus a harness page that iframes `/`, drives it (click, dispatch `keydown`, stub `confirm`), and writes PASS/FAIL lines into its own DOM for `--dump-dom` to print.
|
||||||
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
@@ -59,10 +78,10 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
|
|||||||
- **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`).
|
- **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`).
|
||||||
- **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`.
|
- **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`.
|
||||||
- **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`.
|
- **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).
|
- **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 keys. Renaming or adding a param means touching all five plus `analysisParams()`/`cachedParamsMatch()`/`initSettings()` (settings.js), `api.analyze()` and `_cached_analysis_params()`. Query-string parsing/clamping for all three goes through the single `_qs_float()` helper (see the threshold→margin change `c84b7d8` and the min_duration addition).
|
||||||
- **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.
|
- **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. `_cached_analysis_params()` just `json.load`s the file — key order carries no meaning. (It used to regex the four param keys out of the first 256 bytes, which forced them to be written first; that was worth it only while caches embedded the RMS arrays, and those are gone.) `detector` is `DETECTOR_VERSION`: bump it whenever detection/scoring changes make old cached results wrong (v2 = onset-capped scores, v3 = a section open at EOF ends at its last loud window rather than at the file duration); caches with another version (or missing keys) never match and get overwritten on the next analyse.
|
||||||
- **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.
|
- **Analyze responses:** `/api/analyze` returns only `sections` and `duration` — no RMS data of any kind. `rms_display` (~800-point waveform preview), the full per-window list, and `window` (the window length, which only existed to place RMS bars) were all dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed `rms`/`rms_display` have both keys popped when served from cache.
|
||||||
- **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.
|
- **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/js/clips.js`); 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.
|
||||||
- **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).
|
- **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).
|
||||||
- **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.
|
- **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.
|
||||||
- **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir.
|
- **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir.
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,9 @@ 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 webui.html ./
|
COPY isr.py web.py ./
|
||||||
|
COPY webui/ ./webui/
|
||||||
|
COPY isrweb/ ./isrweb/
|
||||||
|
|
||||||
RUN mkdir -p /recordings
|
RUN mkdir -p /recordings
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ format = wav # wav | flac
|
|||||||
| `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` |
|
| `<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. It lists **hardware cards only** — a PCM name from `asound.conf` never appears there but can still be used directly, including on hosts where no hardware card is enumerable at all. An unrecognised value is passed to ALSA verbatim, so a typo shows up as an `arecord` error in the log rather than at startup.
|
||||||
|
|
||||||
FLAC output requires `pip install soundfile numpy`.
|
FLAC output requires `pip install soundfile numpy`.
|
||||||
|
|
||||||
@@ -149,30 +149,33 @@ python web.py --min-duration 1 # discard loud sections shorter than
|
|||||||
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
|
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.
|
The browser UI lives in the `webui/` folder (`index.html`, `style.css` and plain JavaScript modules in `webui/js/` — no build step), which `web.py` serves under `/ui/`. `web.py` also imports the `isrweb/` package (the loud-section detector and the audio-format helpers) — keep `web.py`, `webui/` and `isrweb/` 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 the date in words (`Today, Saturday 17 October`, `Yesterday, …`), recording count, total duration, and total size. Today is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
|
||||||
|
- **Recordings named by time** — each row is named by the wall-clock span it covers (`22:00 to 23:00`, `12:00 to now` while recording), taken from the filename clock; that name is the table's row header and what every button says (`Play 22:00 to 23:00`). Format, size and the raw filename are in the last column.
|
||||||
- **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.
|
- **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.
|
- **Row layout** — columns are Time, Length, Loudness, Player, Options, File. The **Player** cell holds the `Play` button; pressing it opens the audio player right there in the same cell (the button becomes `Hide`). The **Options** cell next to it is always available: Download, Cut, Delete.
|
||||||
|
- **Inline playback** — the player opens in the row's Player cell; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
|
||||||
- **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.
|
- **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 15–30 s) when a single event generates many timestamps due to brief quiet gaps within it.
|
- **Grace period** — configurable under **Settings** (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 15–30 s) when a single event generates many timestamps due to brief quiet gaps within it.
|
||||||
- **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.
|
- **Min duration** — configurable under **Settings** (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.
|
||||||
- **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.
|
- **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 **When a clip ends** choice in the player bar picks what plays next: **Stop**, **Next in time**, or **Next loudest** — it never changes what J/K do, and it is remembered in the browser along with the pre-roll. 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. **Escape** in the player bar closes it and returns focus to where you were.
|
||||||
- **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`.
|
- **Media keys and headsets** — previous/next track step through the sections like J/K: AirPods and headset buttons, the iOS lock screen and Control Center, and the media keys on a keyboard. The lock screen shows the same clip label as the player bar.
|
||||||
|
- **Cut & download** — the `Cut` button in the Options cell expands the start/end fields right below it (and collapses them again); focus goes to Start, and playing a section pre-fills both. 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>`, then moves focus to the next recording's Play button.
|
||||||
- **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).
|
- **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).
|
||||||
- **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`.
|
- **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.
|
- **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.
|
- **Built for screen readers** — landmarks and headings (one heading per day, the clip player has its own), analysis settings tucked behind a collapsed **Settings** disclosure, skip link, `aria-expanded`/`aria-controls` on every toggle; all information is plain text and buttons in linear reading order, with no visual-only elements. Focus is never dropped: a busy button stays focused while it works, finished analyses move focus to their first section, and background reloads (a recording rolling over) rebuild only the day that changed, so the rest of the page — and your reading position — stays put. Things you asked for are announced (analysis results, highlights ready, refresh and filter counts); background events stay silent.
|
||||||
|
|
||||||
### Too many sections per day?
|
### 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:
|
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.
|
1. **Review by rank instead of thinning the list** — use **U**/**I** (or **Next loudest** in the player bar) 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 → 15–30 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.
|
2. **Raise the grace period** (2 → 15–30 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 → 15–18 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
|
3. **Raise the margin** (12 → 15–18 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
|
||||||
4. **Raise min duration** (0.5 → 1–2 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
|
4. **Raise min duration** (0.5 → 1–2 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
|
||||||
@@ -190,12 +193,16 @@ Everything the UI does goes through these endpoints, so they can also be scripte
|
|||||||
| `GET /api/clip?file=&start=&end=` | Section of a WAV/FLAC decoded server-side, returned as a standalone WAV (max 600 s) |
|
| `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 /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 /stream/<name>` | Inline playback with HTTP Range support; live files get an on-the-fly patched header |
|
||||||
| `GET /download/<name>` | Raw file download |
|
| `GET /download/<name>` | Raw file download, with HTTP Range support so an interrupted transfer can resume |
|
||||||
| `GET /api/status` | Currently recording files (`status.json` passthrough) |
|
| `GET /api/status` | Currently recording files (`status.json` passthrough) |
|
||||||
| `GET /api/storage` | Disk free/total |
|
| `GET /api/storage` | Disk free/total |
|
||||||
| `GET /api/config` | Server-side defaults for margin, min-gap, and min-duration (seeds the UI controls) |
|
| `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 |
|
| `DELETE /api/files/<name>` | Delete a recording and its analysis cache |
|
||||||
|
|
||||||
|
The UI itself is `GET /` plus its static files under `GET /ui/<path>`.
|
||||||
|
|
||||||
|
`HEAD` is answered for `/`, `/ui/<path>`, `/stream/<name>` and `/download/<name>` (headers only, no body) — the generated endpoints (`/api/clip`, `/api/cut`) return `405` rather than doing the work and discarding it.
|
||||||
|
|
||||||
Analysis, clips, cut, and delete return `409` for files that are still being recorded.
|
Analysis, clips, cut, and delete return `409` for files that are still being recorded.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -208,6 +215,8 @@ Analysis, clips, cut, and delete return `409` for files that are still being rec
|
|||||||
|
|
||||||
Both recorder types run in separate threads and retry independently up to `max_retries`.
|
Both recorder types run in separate threads and retry independently up to `max_retries`.
|
||||||
|
|
||||||
|
Soundcard capture is health-checked twice a second: if `arecord` exits, or delivers no audio for 15 seconds while still running (device unplugged, a `dsnoop` device that went away), the source is torn down and reopened as a normal retry. Only a stream that actually delivered audio resets the retry counter, so a device that opens but never captures stops after `max_retries` instead of producing an endless series of empty split files.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker notes
|
## Docker notes
|
||||||
|
|||||||
+3
-13
@@ -67,16 +67,14 @@ format = auto
|
|||||||
#
|
#
|
||||||
# # Device selection (use one of these methods):
|
# # Device selection (use one of these methods):
|
||||||
# # device = default - Use system default input device
|
# # device = default - Use system default input device
|
||||||
# # device = monitor - Auto-select first monitor/loopback source (for system audio)
|
|
||||||
# # device = <name> - Match device by partial name (e.g., "Stereo Mix")
|
# # device = <name> - Match device by partial name (e.g., "Stereo Mix")
|
||||||
# # device = <id> - Use exact device ID from --list-devices
|
# # device = <id> - Use exact device ID from --list-devices
|
||||||
|
# # device = <pcm> - Any ALSA PCM name from asound.conf (e.g. shared_mic);
|
||||||
|
# # these never show up in --list-devices but work anyway
|
||||||
# #
|
# #
|
||||||
# # To list available devices, run: python isr.py --list-devices
|
# # To list available devices, run: python isr.py --list-devices
|
||||||
# device = default
|
# device = default
|
||||||
#
|
#
|
||||||
# # Audio backend (only 'alsa' is supported):
|
|
||||||
# # backend = alsa
|
|
||||||
#
|
|
||||||
# # Sample rate in Hz (common: 44100, 48000, 96000)
|
# # Sample rate in Hz (common: 44100, 48000, 96000)
|
||||||
# sample_rate = 44100
|
# sample_rate = 44100
|
||||||
#
|
#
|
||||||
@@ -122,17 +120,10 @@ format = auto
|
|||||||
#
|
#
|
||||||
# Run: python isr.py --list-devices (or: arecord -l) to list devices.
|
# Run: python isr.py --list-devices (or: arecord -l) to list devices.
|
||||||
#
|
#
|
||||||
# EASY: Use the "monitor" keyword to capture system/loopback audio:
|
# Specify a device by ALSA hardware ID from --list-devices:
|
||||||
# [system_audio]
|
|
||||||
# type = soundcard
|
|
||||||
# device = monitor
|
|
||||||
# format = wav
|
|
||||||
#
|
|
||||||
# MANUAL: Specify a device by ALSA hardware ID from --list-devices:
|
|
||||||
# [usb_mic]
|
# [usb_mic]
|
||||||
# type = soundcard
|
# type = soundcard
|
||||||
# device = hw:1,0
|
# device = hw:1,0
|
||||||
# backend = alsa
|
|
||||||
# sample_rate = 44100
|
# sample_rate = 44100
|
||||||
# channels = 2
|
# channels = 2
|
||||||
# format = flac
|
# format = flac
|
||||||
@@ -148,7 +139,6 @@ format = auto
|
|||||||
# [usb_mic_shared]
|
# [usb_mic_shared]
|
||||||
# type = soundcard
|
# type = soundcard
|
||||||
# device = shared_mic
|
# device = shared_mic
|
||||||
# backend = alsa
|
|
||||||
# sample_rate = 48000
|
# sample_rate = 48000
|
||||||
# channels = 2
|
# channels = 2
|
||||||
# format = flac
|
# format = flac
|
||||||
|
|||||||
@@ -50,118 +50,34 @@ FILENAME_FORMAT = '%Y%m%d_%H%M%S'
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Audio Device & Backend System
|
# Audio Device Discovery (ALSA)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioDevice:
|
class AudioDevice:
|
||||||
"""Represents an audio input device."""
|
"""An ALSA capture device.
|
||||||
id: str # Backend-specific identifier
|
|
||||||
name: str # Human-readable name
|
Only the identity matters: capture format comes from config (sample_rate,
|
||||||
channels: int # Max input channels
|
channels), not from the device, so this deliberately carries no
|
||||||
sample_rate: int # Default sample rate
|
capability fields — reporting a card's channel count here would just be a
|
||||||
backend: str # Backend name ('alsa')
|
number that has no bearing on what actually gets recorded.
|
||||||
is_default: bool = False # Is system default
|
"""
|
||||||
is_monitor: bool = False # Is a monitor/loopback source
|
id: str # ALSA device spec: 'hw:1,0', or a PCM name
|
||||||
|
name: str # Human-readable name from `arecord -l`
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
flags = []
|
return f"{self.name} ({self.id})" + (" [DEFAULT]" if self.is_default else "")
|
||||||
if self.is_default:
|
|
||||||
flags.append("DEFAULT")
|
|
||||||
if self.is_monitor:
|
|
||||||
flags.append("MONITOR")
|
|
||||||
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
|
||||||
return f"{self.name}{flag_str} ({self.backend})"
|
|
||||||
|
|
||||||
|
|
||||||
class AudioBackend(ABC):
|
|
||||||
"""Abstract base for audio capture backends."""
|
|
||||||
|
|
||||||
name: str = "base"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
"""Check if this backend can be used on the current system."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def list_devices(cls) -> List[AudioDevice]:
|
|
||||||
"""List all available input devices."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
|
|
||||||
callback: Callable[[bytes], None]) -> Any:
|
|
||||||
"""Open an audio capture stream. Returns a context manager."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ALSABackend(AudioBackend):
|
|
||||||
"""ALSA backend using arecord (raw PCM output, no sound server required)."""
|
|
||||||
|
|
||||||
name = "alsa"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
return shutil.which('arecord') is not None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def list_devices(cls) -> List[AudioDevice]:
|
|
||||||
devices = []
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['arecord', '-l'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return devices
|
|
||||||
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if not line.startswith('card '):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
card_part, rest = line.split(':', 1)
|
|
||||||
card_num = card_part.replace('card', '').strip()
|
|
||||||
|
|
||||||
if ', device ' in rest:
|
|
||||||
dev_part = rest.split(', device ')[1]
|
|
||||||
dev_num = dev_part.split(':')[0].strip()
|
|
||||||
else:
|
|
||||||
dev_num = '0'
|
|
||||||
|
|
||||||
long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
|
|
||||||
hw_id = f"hw:{card_num},{dev_num}"
|
|
||||||
|
|
||||||
devices.append(AudioDevice(
|
|
||||||
id=hw_id,
|
|
||||||
name=long_name,
|
|
||||||
channels=2,
|
|
||||||
sample_rate=44100,
|
|
||||||
backend=cls.name,
|
|
||||||
is_default=(card_num == '0' and dev_num == '0'),
|
|
||||||
is_monitor=False,
|
|
||||||
))
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
|
||||||
self.logger = logger
|
|
||||||
|
|
||||||
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
|
|
||||||
callback: Callable[[bytes], None]):
|
|
||||||
return ALSAStream(device, sample_rate, channels, callback, self.logger)
|
|
||||||
|
|
||||||
|
|
||||||
class ALSAStream:
|
class ALSAStream:
|
||||||
"""Context manager for ALSA recording using arecord (raw PCM output)."""
|
"""Context manager for ALSA recording using arecord (raw PCM output)."""
|
||||||
|
|
||||||
|
# No audio for this long means capture is dead even though the process is
|
||||||
|
# still up (device yanked, dsnoop shm gone). Generous enough that normal
|
||||||
|
# scheduling hiccups never trip it.
|
||||||
|
SILENCE_TIMEOUT = 15.0
|
||||||
|
|
||||||
def __init__(self, device: AudioDevice, sample_rate: int, channels: int,
|
def __init__(self, device: AudioDevice, sample_rate: int, channels: int,
|
||||||
callback: Callable[[bytes], None], logger: logging.Logger):
|
callback: Callable[[bytes], None], logger: logging.Logger):
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -169,12 +85,16 @@ class ALSAStream:
|
|||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self.received_audio = False
|
||||||
self._running = False
|
self._running = False
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._stderr_thread = None
|
||||||
self._process = None
|
self._process = None
|
||||||
|
self._last_data = 0.0
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self._running = True
|
self._running = True
|
||||||
|
self._last_data = time.monotonic()
|
||||||
cmd = [
|
cmd = [
|
||||||
'arecord',
|
'arecord',
|
||||||
'-D', self.device.id,
|
'-D', self.device.id,
|
||||||
@@ -207,8 +127,25 @@ class ALSAStream:
|
|||||||
self._process.wait(timeout=2)
|
self._process.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self._process.kill()
|
self._process.kill()
|
||||||
if self._thread:
|
for thread in (self._thread, self._stderr_thread):
|
||||||
self._thread.join(timeout=1)
|
if thread:
|
||||||
|
thread.join(timeout=1)
|
||||||
|
|
||||||
|
def check_alive(self):
|
||||||
|
"""Raise if capture has stopped delivering audio.
|
||||||
|
|
||||||
|
Nothing else notices a dead arecord: _read_loop just returns, so
|
||||||
|
without this the recorder would keep rolling over to new split files
|
||||||
|
on schedule and write nothing into any of them — a failure that is
|
||||||
|
invisible from the outside because the files still appear on time.
|
||||||
|
"""
|
||||||
|
rc = self._process.poll()
|
||||||
|
if rc is not None:
|
||||||
|
raise RuntimeError(f"arecord exited with code {rc}")
|
||||||
|
idle = time.monotonic() - self._last_data
|
||||||
|
if idle > self.SILENCE_TIMEOUT:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"no audio from '{self.device.id}' for {idle:.0f}s")
|
||||||
|
|
||||||
def _log_stderr(self):
|
def _log_stderr(self):
|
||||||
for line in self._process.stderr:
|
for line in self._process.stderr:
|
||||||
@@ -221,7 +158,10 @@ class ALSAStream:
|
|||||||
while self._running and self._process.poll() is None:
|
while self._running and self._process.poll() is None:
|
||||||
try:
|
try:
|
||||||
data = self._process.stdout.read(chunk_size)
|
data = self._process.stdout.read(chunk_size)
|
||||||
if data:
|
if not data:
|
||||||
|
break # EOF: arecord is on its way out, don't spin
|
||||||
|
self._last_data = time.monotonic()
|
||||||
|
self.received_audio = True
|
||||||
self.callback(data)
|
self.callback(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"ALSA read error: {e}")
|
self.logger.error(f"ALSA read error: {e}")
|
||||||
@@ -229,95 +169,94 @@ class ALSAStream:
|
|||||||
|
|
||||||
|
|
||||||
class AudioSystem:
|
class AudioSystem:
|
||||||
"""Manages audio backends and device discovery."""
|
"""Discovers ALSA capture devices and opens capture streams.
|
||||||
|
|
||||||
# All available backend classes
|
ALSA (via `arecord`) is the only backend. This was once a plugin system
|
||||||
_backend_classes: List[type] = [ALSABackend]
|
with a registry and an abstract base class; there has only ever been one
|
||||||
|
implementation, so it is a plain class now.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
def __init__(self, logger: logging.Logger):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._backends: Dict[str, AudioBackend] = {}
|
self.available = shutil.which('arecord') is not None
|
||||||
self._discover_backends()
|
|
||||||
|
|
||||||
def _discover_backends(self):
|
def list_devices(self) -> List[AudioDevice]:
|
||||||
"""Find and initialize available backends."""
|
"""Hardware capture devices, parsed out of `arecord -l`.
|
||||||
for cls in self._backend_classes:
|
|
||||||
if cls.is_available():
|
|
||||||
self._backends[cls.name] = cls(self.logger)
|
|
||||||
self.logger.debug(f"Audio backend available: {cls.name}")
|
|
||||||
|
|
||||||
@property
|
Virtual PCMs defined in asound.conf (dsnoop, plug, ...) are NOT listed
|
||||||
def available_backends(self) -> List[str]:
|
by `arecord -l` and so never appear here — see find_device().
|
||||||
return list(self._backends.keys())
|
"""
|
||||||
|
devices = []
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['arecord', '-l'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return devices
|
||||||
|
|
||||||
def get_backend(self, name: str) -> Optional[AudioBackend]:
|
for line in result.stdout.split('\n'):
|
||||||
return self._backends.get(name)
|
if not line.startswith('card '):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
card_part, rest = line.split(':', 1)
|
||||||
|
card_num = card_part.replace('card', '').strip()
|
||||||
|
|
||||||
def list_all_devices(self) -> List[AudioDevice]:
|
if ', device ' in rest:
|
||||||
"""List devices from all available backends."""
|
dev_part = rest.split(', device ')[1]
|
||||||
all_devices = []
|
dev_num = dev_part.split(':')[0].strip()
|
||||||
seen_names = set()
|
else:
|
||||||
|
dev_num = '0'
|
||||||
|
|
||||||
for cls in self._backend_classes:
|
long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
|
||||||
if cls.name in self._backends:
|
|
||||||
for dev in cls.list_devices():
|
|
||||||
# Deduplicate by name (same device may appear in multiple backends)
|
|
||||||
key = dev.name.lower()
|
|
||||||
if key not in seen_names:
|
|
||||||
all_devices.append(dev)
|
|
||||||
seen_names.add(key)
|
|
||||||
|
|
||||||
return all_devices
|
devices.append(AudioDevice(
|
||||||
|
id=f"hw:{card_num},{dev_num}",
|
||||||
|
name=long_name,
|
||||||
|
is_default=(card_num == '0' and dev_num == '0'),
|
||||||
|
))
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return devices
|
||||||
|
|
||||||
def find_device(self, spec: str, preferred_backend: str = None) -> Optional[AudioDevice]:
|
def find_device(self, spec: str) -> AudioDevice:
|
||||||
"""Find a device by ID, name, or pattern."""
|
"""Resolve a config `device =` value to an ALSA device.
|
||||||
devices = self.list_all_devices()
|
|
||||||
|
|
||||||
if not devices:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# "default" - get the default device from preferred backend
|
|
||||||
if spec.lower() == 'default':
|
|
||||||
for dev in devices:
|
|
||||||
if dev.is_default:
|
|
||||||
if preferred_backend is None or dev.backend == preferred_backend:
|
|
||||||
return dev
|
|
||||||
return devices[0] if devices else None
|
|
||||||
|
|
||||||
# "monitor" - get first monitor source
|
|
||||||
if spec.lower() == 'monitor':
|
|
||||||
for dev in devices:
|
|
||||||
if dev.is_monitor:
|
|
||||||
if preferred_backend is None or dev.backend == preferred_backend:
|
|
||||||
return dev
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
Never returns None. An unrecognised spec is passed to ALSA verbatim,
|
||||||
|
because a valid PCM name from asound.conf (`shared_mic`, `plughw:0`)
|
||||||
|
does not appear in `arecord -l` — and that must keep working even on a
|
||||||
|
host where no hardware card is enumerable at all, which is exactly the
|
||||||
|
case an earlier "no devices, give up" guard broke.
|
||||||
|
"""
|
||||||
|
devices = self.list_devices()
|
||||||
spec_lower = spec.lower()
|
spec_lower = spec.lower()
|
||||||
|
|
||||||
# Try exact ID match first
|
if spec_lower == 'default':
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
if dev.id == spec:
|
if dev.is_default:
|
||||||
return dev
|
return dev
|
||||||
|
# 'default' is itself a valid ALSA PCM when no card is enumerable.
|
||||||
|
return devices[0] if devices else AudioDevice(id='default', name='default')
|
||||||
|
|
||||||
# Try exact name match
|
exact_id = [d for d in devices if d.id == spec]
|
||||||
for dev in devices:
|
exact_name = [d for d in devices if d.name.lower() == spec_lower]
|
||||||
if dev.name.lower() == spec_lower:
|
partial = [d for d in devices if spec_lower in d.name.lower()]
|
||||||
return dev
|
for candidates in (exact_id, exact_name, partial):
|
||||||
|
if candidates:
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
# Try partial name match
|
self.logger.info(
|
||||||
for dev in devices:
|
f"Device '{spec}' is not in `arecord -l` — using it as an ALSA PCM name")
|
||||||
if spec_lower in dev.name.lower():
|
return AudioDevice(id=spec, name=spec)
|
||||||
return dev
|
|
||||||
|
|
||||||
# No hardware device matched. Treat spec as a direct ALSA PCM name so
|
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
|
||||||
# virtual devices defined in asound.conf (dsnoop, plug, etc.) work without
|
callback: Callable[[bytes], None]) -> 'ALSAStream':
|
||||||
# appearing in `arecord -l`.
|
"""Open a capture stream. Returns a context manager."""
|
||||||
if 'alsa' in self._backends:
|
return ALSAStream(device, sample_rate, channels, callback, self.logger)
|
||||||
return AudioDevice(
|
|
||||||
id=spec, name=spec, channels=2, sample_rate=44100, backend='alsa'
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class BaseRecorder(ABC):
|
class BaseRecorder(ABC):
|
||||||
@@ -333,8 +272,9 @@ class BaseRecorder(ABC):
|
|||||||
self.current_filename = None
|
self.current_filename = None
|
||||||
self._clock = clock or datetime.now
|
self._clock = clock or datetime.now
|
||||||
|
|
||||||
# Common settings
|
# Common settings. split_duration is clamped: 0 would divide by zero
|
||||||
self.split_duration = config.get('split_minutes', 60)
|
# in get_next_split_time(), negative would split every tick.
|
||||||
|
self.split_duration = max(1, config.get('split_minutes', 60))
|
||||||
self.output_dir = config.get('output_directory', 'recordings')
|
self.output_dir = config.get('output_directory', 'recordings')
|
||||||
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)
|
||||||
@@ -646,7 +586,7 @@ class _AudioFileWriter:
|
|||||||
|
|
||||||
|
|
||||||
class SoundcardRecorder(BaseRecorder):
|
class SoundcardRecorder(BaseRecorder):
|
||||||
"""Records from soundcard/audio devices using multiple backends."""
|
"""Records from a soundcard/audio device via ALSA."""
|
||||||
|
|
||||||
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
|
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
|
||||||
audio_system: 'AudioSystem' = None,
|
audio_system: 'AudioSystem' = None,
|
||||||
@@ -656,9 +596,9 @@ class SoundcardRecorder(BaseRecorder):
|
|||||||
# Initialize audio system if not provided
|
# Initialize audio system if not provided
|
||||||
self.audio_system = audio_system or AudioSystem(logger)
|
self.audio_system = audio_system or AudioSystem(logger)
|
||||||
|
|
||||||
if not self.audio_system.available_backends:
|
if not self.audio_system.available:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"No audio backends available.\n"
|
"arecord not found — soundcard capture is unavailable.\n"
|
||||||
"Install ALSA utilities: sudo apt install alsa-utils\n"
|
"Install ALSA utilities: sudo apt install alsa-utils\n"
|
||||||
"(In Docker, also ensure /dev/snd is mapped into the container)"
|
"(In Docker, also ensure /dev/snd is mapped into the container)"
|
||||||
)
|
)
|
||||||
@@ -667,7 +607,6 @@ class SoundcardRecorder(BaseRecorder):
|
|||||||
self.device_spec = config.get('device', 'default')
|
self.device_spec = config.get('device', 'default')
|
||||||
self.sample_rate = config.get('sample_rate', 44100)
|
self.sample_rate = config.get('sample_rate', 44100)
|
||||||
self.channels = config.get('channels', 2)
|
self.channels = config.get('channels', 2)
|
||||||
self.preferred_backend = config.get('backend', None)
|
|
||||||
|
|
||||||
# Validate format
|
# Validate format
|
||||||
if self.file_format not in ('wav', 'flac') and self.file_format != 'auto':
|
if self.file_format not in ('wav', 'flac') and self.file_format != 'auto':
|
||||||
@@ -684,30 +623,25 @@ class SoundcardRecorder(BaseRecorder):
|
|||||||
self.buffer_lock = threading.Lock()
|
self.buffer_lock = threading.Lock()
|
||||||
|
|
||||||
def _resolve_device(self) -> AudioDevice:
|
def _resolve_device(self) -> AudioDevice:
|
||||||
"""Resolve device specification to AudioDevice."""
|
"""Resolve the configured device spec to an AudioDevice.
|
||||||
device = self.audio_system.find_device(self.device_spec, self.preferred_backend)
|
|
||||||
|
|
||||||
if device is None:
|
|
||||||
# List available devices in error
|
|
||||||
available = self.audio_system.list_all_devices()
|
|
||||||
device_list = "\n ".join(str(d) for d in available) if available else "None found"
|
|
||||||
raise ValueError(
|
|
||||||
f"[{self.name}] Device '{self.device_spec}' not found.\n"
|
|
||||||
f"Available devices:\n {device_list}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
find_device() always succeeds — an unknown spec becomes a literal ALSA
|
||||||
|
PCM name — so a typo surfaces as an arecord failure at capture time
|
||||||
|
rather than here. That is the price of supporting asound.conf PCMs,
|
||||||
|
which are invisible to `arecord -l`.
|
||||||
|
"""
|
||||||
|
device = self.audio_system.find_device(self.device_spec)
|
||||||
self.logger.info(f"[{self.name}] Using device: {device}")
|
self.logger.info(f"[{self.name}] Using device: {device}")
|
||||||
return device
|
return device
|
||||||
|
|
||||||
def _audio_callback(self, data: bytes):
|
def _audio_callback(self, data: bytes):
|
||||||
"""Callback for audio data from backend."""
|
"""Callback for audio data from the capture thread."""
|
||||||
with self.buffer_lock:
|
with self.buffer_lock:
|
||||||
self.audio_buffer.append(data)
|
self.audio_buffer.append(data)
|
||||||
|
|
||||||
def _open_output_file(self):
|
def _open_output_file(self):
|
||||||
"""Open a new WAV or FLAC output file for recording."""
|
"""Open a new WAV or FLAC output file for recording."""
|
||||||
self._flush_buffer_to_file()
|
self.close_current_file() # overridden below to flush first
|
||||||
self.close_current_file()
|
|
||||||
|
|
||||||
self.current_filename = self.generate_filename(self.file_format)
|
self.current_filename = self.generate_filename(self.file_format)
|
||||||
self.current_file = _AudioFileWriter(
|
self.current_file = _AudioFileWriter(
|
||||||
@@ -738,13 +672,8 @@ class SoundcardRecorder(BaseRecorder):
|
|||||||
retry_count = 0
|
retry_count = 0
|
||||||
next_split = self.get_next_split_time()
|
next_split = self.get_next_split_time()
|
||||||
|
|
||||||
backend = self.audio_system.get_backend(self.device.backend)
|
|
||||||
if not backend:
|
|
||||||
self.logger.error(f"[{self.name}] Backend '{self.device.backend}' not available")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.logger.info(f"[{self.name}] Starting recorder - Split every {self.split_duration} min")
|
self.logger.info(f"[{self.name}] Starting recorder - Split every {self.split_duration} min")
|
||||||
self.logger.info(f"[{self.name}] Backend: {self.device.backend}, Device: {self.device.name}")
|
self.logger.info(f"[{self.name}] Device: {self.device}")
|
||||||
self.logger.info(f"[{self.name}] Sample rate: {self.sample_rate}, Channels: {self.channels}")
|
self.logger.info(f"[{self.name}] Sample rate: {self.sample_rate}, Channels: {self.channels}")
|
||||||
self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}")
|
self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
@@ -753,15 +682,23 @@ class SoundcardRecorder(BaseRecorder):
|
|||||||
if self.current_file is None:
|
if self.current_file is None:
|
||||||
self._open_output_file()
|
self._open_output_file()
|
||||||
|
|
||||||
with backend.open_stream(
|
with self.audio_system.open_stream(
|
||||||
self.device, self.sample_rate, self.channels, self._audio_callback
|
self.device, self.sample_rate, self.channels, self._audio_callback
|
||||||
):
|
) as stream:
|
||||||
self.logger.info(f"[{self.name}] Recording started")
|
self.logger.info(f"[{self.name}] Recording started")
|
||||||
retry_count = 0
|
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
self._flush_buffer_to_file()
|
self._flush_buffer_to_file()
|
||||||
|
# Raises if capture died; the retry logic below then
|
||||||
|
# reopens the device instead of silently recording
|
||||||
|
# empty files forever.
|
||||||
|
stream.check_alive()
|
||||||
|
# Only a stream that actually delivered audio counts as
|
||||||
|
# progress. Resetting on every healthy tick would let a
|
||||||
|
# device that opens but never captures retry forever.
|
||||||
|
if stream.received_audio:
|
||||||
|
retry_count = 0
|
||||||
|
|
||||||
if self._clock() >= next_split:
|
if self._clock() >= next_split:
|
||||||
self._open_output_file()
|
self._open_output_file()
|
||||||
@@ -809,24 +746,26 @@ class RecorderManager:
|
|||||||
config.read(self.config_file)
|
config.read(self.config_file)
|
||||||
|
|
||||||
# Get general settings (defaults)
|
# Get general settings (defaults)
|
||||||
|
# Defaults every source inherits. Logging is read separately — it is
|
||||||
|
# a process-wide setting, not something a recorder ever looks at.
|
||||||
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),
|
||||||
'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_file': config.get('general', 'log_file', fallback='recorder.log'),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.output_dir = general['output_directory']
|
self.output_dir = general['output_directory']
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
self._setup_logging(general['log_level'], general['log_file'])
|
self._setup_logging(
|
||||||
|
config.get('general', 'log_level', fallback='INFO').upper(),
|
||||||
|
config.get('general', 'log_file', fallback='recorder.log'))
|
||||||
|
|
||||||
# Initialize shared audio system for soundcard recorders
|
# Initialize shared audio system for soundcard recorders
|
||||||
self.audio_system = AudioSystem(self.logger)
|
self.audio_system = AudioSystem(self.logger)
|
||||||
if self.audio_system.available_backends:
|
if self.audio_system.available:
|
||||||
self.logger.info(f"Audio backends: {', '.join(self.audio_system.available_backends)}")
|
self.logger.info("Audio capture available: ALSA (arecord)")
|
||||||
|
|
||||||
# Parse source sections
|
# Parse source sections
|
||||||
for section in config.sections():
|
for section in config.sections():
|
||||||
@@ -838,7 +777,10 @@ class RecorderManager:
|
|||||||
self.logger.warning(f"Section [{section}] has no 'type', skipping")
|
self.logger.warning(f"Section [{section}] has no 'type', skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build source config by merging general with section-specific
|
# Build the source config and create the recorder. Both are inside
|
||||||
|
# the try: a non-numeric split_minutes used to raise out here and
|
||||||
|
# abort the whole config load instead of skipping one section.
|
||||||
|
try:
|
||||||
source_config = general.copy()
|
source_config = general.copy()
|
||||||
|
|
||||||
for key, value in config.items(section):
|
for key, value in config.items(section):
|
||||||
@@ -849,8 +791,6 @@ class RecorderManager:
|
|||||||
else:
|
else:
|
||||||
source_config[key] = value
|
source_config[key] = value
|
||||||
|
|
||||||
# Create recorder
|
|
||||||
try:
|
|
||||||
if source_type == 'stream':
|
if source_type == 'stream':
|
||||||
recorder = StreamRecorder(section, source_config, self.logger)
|
recorder = StreamRecorder(section, source_config, self.logger)
|
||||||
self.recorders.append(recorder)
|
self.recorders.append(recorder)
|
||||||
@@ -968,7 +908,7 @@ class RecorderManager:
|
|||||||
|
|
||||||
|
|
||||||
def list_audio_devices():
|
def list_audio_devices():
|
||||||
"""List available audio input devices from all backends."""
|
"""Print the ALSA capture devices usable as `device =` in config.ini."""
|
||||||
# Create a minimal logger for device listing
|
# Create a minimal logger for device listing
|
||||||
logger = logging.getLogger('isr-devices')
|
logger = logging.getLogger('isr-devices')
|
||||||
logger.setLevel(logging.WARNING) # Suppress debug output
|
logger.setLevel(logging.WARNING) # Suppress debug output
|
||||||
@@ -977,40 +917,36 @@ def list_audio_devices():
|
|||||||
print(" ISR Audio Device Discovery")
|
print(" ISR Audio Device Discovery")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
if not ALSABackend.is_available():
|
audio_system = AudioSystem(logger)
|
||||||
print("\n No audio backends available!")
|
if not audio_system.available:
|
||||||
|
print("\n arecord not found!")
|
||||||
print("\n Install ALSA utilities:")
|
print("\n Install ALSA utilities:")
|
||||||
print(" sudo apt install alsa-utils")
|
print(" sudo apt install alsa-utils")
|
||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
print("\n Available Backends:")
|
devices = audio_system.list_devices()
|
||||||
print(" - ALSA (arecord)")
|
if devices:
|
||||||
|
print("\n Hardware Input Devices:")
|
||||||
# Initialize audio system and list devices
|
|
||||||
audio_system = AudioSystem(logger)
|
|
||||||
devices = audio_system.list_all_devices()
|
|
||||||
|
|
||||||
if not devices:
|
|
||||||
print("\n No input devices found!")
|
|
||||||
print()
|
|
||||||
return
|
|
||||||
|
|
||||||
print("\n Input Devices:")
|
|
||||||
print(" " + "-" * 68)
|
print(" " + "-" * 68)
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
flag_str = " [DEFAULT]" if dev.is_default else ""
|
flag_str = " [DEFAULT]" if dev.is_default else ""
|
||||||
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}")
|
||||||
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz")
|
else:
|
||||||
|
print("\n No hardware capture devices found.")
|
||||||
|
|
||||||
|
# This listing only ever shows hardware cards, so say so — a working
|
||||||
|
# asound.conf PCM will never appear here and users have gone looking.
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
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 = <name> # Match by partial name")
|
print(" device = <name> # Match by partial name")
|
||||||
print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
|
print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
|
||||||
print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g. shared_mic")
|
print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g.")
|
||||||
|
print(" # shared_mic — these are never listed above,")
|
||||||
|
print(" # but work regardless")
|
||||||
print("=" * 70 + "\n")
|
print("=" * 70 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Supporting modules for web.py (the archive browser)."""
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""What an audio file is, and what its name means.
|
||||||
|
|
||||||
|
Two clusters that share a theme — facts about the files on disk — and share
|
||||||
|
the property of being touched only when a container format misbehaves:
|
||||||
|
|
||||||
|
* the filename clock (recordings are named for their *start* time, and that
|
||||||
|
name is the only reliable time anchor the archive has), and
|
||||||
|
* byte-level format handling: the 44-byte PCM header for clips, duration
|
||||||
|
probing, and the live-header patching that lets a browser show a duration
|
||||||
|
for a file still being recorded.
|
||||||
|
|
||||||
|
Imports nothing from the rest of the app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
SOUNDFILE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
SOUNDFILE_AVAILABLE = False
|
||||||
|
|
||||||
|
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
|
||||||
|
|
||||||
|
# Recording filenames encode the start time as this strftime format (kept in
|
||||||
|
# sync with isr.FILENAME_FORMAT). It is the authoritative recording start and
|
||||||
|
# the only reliable clock anchor — mtime drifts to the last write, so cut clip
|
||||||
|
# names and the displayed date are both derived from this.
|
||||||
|
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
|
||||||
|
|
||||||
|
MIME_TYPES = {
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.flac': 'audio/flac',
|
||||||
|
'.aac': 'audio/aac',
|
||||||
|
'.opus': 'audio/ogg',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The filename clock
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _recording_start(stem: str):
|
||||||
|
"""Parse the recording start time encoded in a filename stem.
|
||||||
|
|
||||||
|
Returns a datetime, or None if the stem is not in FILENAME_FORMAT
|
||||||
|
(e.g. a manually renamed file). strptime ignores any extension because
|
||||||
|
callers pass Path.stem.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(stem, FILENAME_FORMAT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cut_filename(stem: str, ext: str, start: float, end: float) -> str:
|
||||||
|
"""Name a cut by the real wall-clock span it covers.
|
||||||
|
|
||||||
|
For a recording that started at 22:00:00, a 22:31:30→22:32:30 slice
|
||||||
|
(start=1890, end=1950) becomes ``20260523_22-31-30_22-32-30.flac``.
|
||||||
|
Falls back to the source stem plus second offsets when the filename is
|
||||||
|
not in FILENAME_FORMAT (e.g. a manually renamed recording).
|
||||||
|
"""
|
||||||
|
started = _recording_start(stem)
|
||||||
|
if started is None:
|
||||||
|
return f'{stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
||||||
|
cut_start = started + timedelta(seconds=start)
|
||||||
|
cut_end = started + timedelta(seconds=end)
|
||||||
|
return f'{cut_start:%Y%m%d}_{cut_start:%H-%M-%S}_{cut_end:%H-%M-%S}{ext}'
|
||||||
|
|
||||||
|
|
||||||
|
def _live_wav_header(path: Path, size: int):
|
||||||
|
"""Return the WAV header (through the 'data' chunk header) with RIFF and
|
||||||
|
data sizes rewritten to match the current file size, or None.
|
||||||
|
|
||||||
|
While a WAV file is still being recorded its header claims ~0 frames, so
|
||||||
|
browsers show no duration and refuse to seek. Serving a header patched to
|
||||||
|
the bytes recorded so far fixes both; the patch is the same length as the
|
||||||
|
original header, so all byte offsets and Range math stay valid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as fh:
|
||||||
|
hdr = fh.read(512)
|
||||||
|
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
|
||||||
|
return None
|
||||||
|
pos = 12
|
||||||
|
while pos + 8 <= len(hdr):
|
||||||
|
chunk_id = hdr[pos:pos + 4]
|
||||||
|
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
|
||||||
|
if chunk_id == b'data':
|
||||||
|
data_off = pos + 8
|
||||||
|
patched = bytearray(hdr[:data_off])
|
||||||
|
patched[4:8] = (size - 8).to_bytes(4, 'little')
|
||||||
|
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
|
||||||
|
return bytes(patched)
|
||||||
|
pos += 8 + chunk_size + (chunk_size & 1)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# CRC-8 (poly 0x07) used by FLAC frame headers
|
||||||
|
_CRC8_TABLE = []
|
||||||
|
for _i in range(256):
|
||||||
|
_c = _i
|
||||||
|
for _ in range(8):
|
||||||
|
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
|
||||||
|
_CRC8_TABLE.append(_c)
|
||||||
|
|
||||||
|
_FLAC_BLOCKSIZES = {1: 192, 2: 576, 3: 1152, 4: 2304, 5: 4608, 8: 256, 9: 512,
|
||||||
|
10: 1024, 11: 2048, 12: 4096, 13: 8192, 14: 16384, 15: 32768}
|
||||||
|
|
||||||
|
|
||||||
|
def _crc8(data: bytes) -> int:
|
||||||
|
crc = 0
|
||||||
|
for b in data:
|
||||||
|
crc = _CRC8_TABLE[crc ^ b]
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
def _flac_coded_number(buf: bytes, pos: int):
|
||||||
|
"""Decode the UTF-8-style frame/sample number; returns (value, next_pos)."""
|
||||||
|
b0 = buf[pos]
|
||||||
|
if b0 < 0x80:
|
||||||
|
return b0, pos + 1
|
||||||
|
n, mask = 0, 0x40
|
||||||
|
while b0 & mask:
|
||||||
|
n += 1
|
||||||
|
mask >>= 1
|
||||||
|
if n < 1 or n > 6: # 10xxxxxx is not a valid leading byte
|
||||||
|
return None
|
||||||
|
val = b0 & (mask - 1)
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
c = buf[pos + i]
|
||||||
|
if c & 0xC0 != 0x80:
|
||||||
|
return None
|
||||||
|
val = (val << 6) | (c & 0x3F)
|
||||||
|
return val, pos + 1 + n
|
||||||
|
|
||||||
|
|
||||||
|
def _flac_frame_samples(buf: bytes, pos: int, fixed_bs: int):
|
||||||
|
"""If a valid FLAC frame header starts at pos, return the stream sample
|
||||||
|
count through the end of that frame, else None. Validity is confirmed by
|
||||||
|
the header's CRC-8, so false sync matches in compressed data are rejected."""
|
||||||
|
try:
|
||||||
|
variable = buf[pos + 1] & 0x01
|
||||||
|
bs_code = buf[pos + 2] >> 4
|
||||||
|
sr_code = buf[pos + 2] & 0x0F
|
||||||
|
if bs_code == 0 or sr_code == 15 or buf[pos + 3] & 0x01:
|
||||||
|
return None
|
||||||
|
if (buf[pos + 3] >> 4) > 10: # reserved channel assignment
|
||||||
|
return None
|
||||||
|
coded = _flac_coded_number(buf, pos + 4)
|
||||||
|
if coded is None:
|
||||||
|
return None
|
||||||
|
val, p = coded
|
||||||
|
bs = _FLAC_BLOCKSIZES.get(bs_code)
|
||||||
|
if bs_code == 6:
|
||||||
|
bs = buf[p] + 1
|
||||||
|
p += 1
|
||||||
|
elif bs_code == 7:
|
||||||
|
bs = int.from_bytes(buf[p:p + 2], 'big') + 1
|
||||||
|
p += 2
|
||||||
|
if sr_code == 12:
|
||||||
|
p += 1
|
||||||
|
elif sr_code in (13, 14):
|
||||||
|
p += 2
|
||||||
|
if _crc8(buf[pos:p]) != buf[p]:
|
||||||
|
return None
|
||||||
|
if variable: # val is the frame's starting sample number
|
||||||
|
return val + (bs or 0)
|
||||||
|
return val * (fixed_bs or bs or 4096) + (bs or 0)
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _live_flac_header(path: Path, size: int):
|
||||||
|
"""Return the first 26 bytes of a FLAC file with STREAMINFO total_samples
|
||||||
|
patched to the samples recorded so far, or None.
|
||||||
|
|
||||||
|
Like _live_wav_header, but FLAC duration cannot be derived from the byte
|
||||||
|
count (variable compression). Instead the sample count is parsed out of
|
||||||
|
the last frame header in the file tail — each FLAC frame carries its own
|
||||||
|
frame/sample number.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as fh:
|
||||||
|
head = fh.read(42)
|
||||||
|
if len(head) < 42 or head[:4] != b'fLaC':
|
||||||
|
return None
|
||||||
|
# STREAMINFO must be the first metadata block
|
||||||
|
if head[4] & 0x7F != 0 or int.from_bytes(head[5:8], 'big') != 34:
|
||||||
|
return None
|
||||||
|
fixed_bs = int.from_bytes(head[8:10], 'big')
|
||||||
|
|
||||||
|
tail_len = min(size, 65536)
|
||||||
|
fh.seek(size - tail_len)
|
||||||
|
buf = fh.read(tail_len)
|
||||||
|
|
||||||
|
samples = None
|
||||||
|
for i in range(len(buf) - 20, -1, -1):
|
||||||
|
if buf[i] == 0xFF and (buf[i + 1] & 0xFC) == 0xF8:
|
||||||
|
samples = _flac_frame_samples(buf, i, fixed_bs)
|
||||||
|
if samples:
|
||||||
|
break
|
||||||
|
if not samples:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Bytes 18-25 hold: sample rate (20 bits) | channels-1 (3) |
|
||||||
|
# bps-1 (5) | total_samples (36). Replace only the low 36 bits.
|
||||||
|
field = int.from_bytes(head[18:26], 'big')
|
||||||
|
field = (field & ~((1 << 36) - 1)) | min(samples, (1 << 36) - 1)
|
||||||
|
patched = bytearray(head[:26])
|
||||||
|
patched[18:26] = field.to_bytes(8, 'big')
|
||||||
|
return bytes(patched)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_header(n_frames: int, channels: int, framerate: int, sampwidth: int) -> bytes:
|
||||||
|
"""Standard 44-byte PCM WAV header for a clip of known length."""
|
||||||
|
data_len = n_frames * channels * sampwidth
|
||||||
|
byte_rate = framerate * channels * sampwidth
|
||||||
|
return (b'RIFF' + (36 + data_len).to_bytes(4, 'little') + b'WAVE'
|
||||||
|
+ b'fmt ' + (16).to_bytes(4, 'little')
|
||||||
|
+ struct.pack('<HHIIHH', 1, channels, framerate, byte_rate,
|
||||||
|
channels * sampwidth, sampwidth * 8)
|
||||||
|
+ b'data' + data_len.to_bytes(4, 'little'))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_audio_duration(path: Path):
|
||||||
|
"""Return duration in seconds for any supported audio file, or None."""
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
if ext == '.wav':
|
||||||
|
try:
|
||||||
|
with wave.open(str(path), 'rb') as wf:
|
||||||
|
return round(wf.getnframes() / wf.getframerate(), 2)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
|
||||||
|
try:
|
||||||
|
with sf.SoundFile(path) as f:
|
||||||
|
return round(len(f) / f.samplerate, 2)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Adaptive loud-section detection.
|
||||||
|
|
||||||
|
Pure computation: RMS windows -> a rolling noise floor -> scored sections.
|
||||||
|
Nothing here knows about HTTP, caching or the archive on disk, and nothing
|
||||||
|
here imports the rest of the app — so the detector can be worked on, and
|
||||||
|
tested, on its own. That matters because this is the part most likely to
|
||||||
|
change: if flagging (rather than ranking) ever needs improving, the next
|
||||||
|
step is a spectral filter or an optional Silero VAD over candidate sections,
|
||||||
|
and it all lands in this file.
|
||||||
|
|
||||||
|
**Detection is adaptive — do not regress it to an absolute threshold.** A
|
||||||
|
fixed RMS threshold flagged every ambience change (passing cars, rain) and
|
||||||
|
produced ~600 useless sections a day; that is why it was replaced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
NUMPY_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
NUMPY_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
SOUNDFILE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
SOUNDFILE_AVAILABLE = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tuning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
|
||||||
|
MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
|
||||||
|
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
|
||||||
|
MIN_DURATION_SECONDS = 0.5 # discard loud sections shorter than this
|
||||||
|
|
||||||
|
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
|
||||||
|
NOISE_PERCENTILE = 20 # percentile of windowed dB levels taken as the floor
|
||||||
|
MIN_RMS = 0.002 # ≈ −54 dBFS; the floor never drops below this, so
|
||||||
|
# digital silence does not make every tiny sound loud
|
||||||
|
ONSET_SECONDS = 0.5 # a section's score is capped by its sharpest dB rise
|
||||||
|
# within this span, so slow swells rank low
|
||||||
|
|
||||||
|
# Bumped whenever section detection/scoring changes in a way that makes old
|
||||||
|
# cached results wrong (not just differently parameterised). Caches written
|
||||||
|
# with another version never match and are recomputed on the next analyse.
|
||||||
|
DETECTOR_VERSION = 3 # v3: a section open at EOF ends at its last loud
|
||||||
|
# window, not at the file duration
|
||||||
|
|
||||||
|
def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
|
||||||
|
window_samples: int):
|
||||||
|
"""Yield rms_0_to_1 for every window in the open wave file."""
|
||||||
|
while True:
|
||||||
|
raw = wf.readframes(window_samples)
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
n_samp = len(raw) // (sampwidth * channels)
|
||||||
|
if n_samp == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
if NUMPY_AVAILABLE:
|
||||||
|
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
|
||||||
|
if channels > 1:
|
||||||
|
arr = arr.reshape(-1, channels).mean(axis=1)
|
||||||
|
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
|
||||||
|
else:
|
||||||
|
fmt = f'<{n_samp * channels}h'
|
||||||
|
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
|
||||||
|
mono = samples[::channels] if channels > 1 else samples
|
||||||
|
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
|
||||||
|
|
||||||
|
yield round(rms, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _noise_floor_db(db_values: list, window_dur: float) -> list:
|
||||||
|
"""Per-window background noise floor in dBFS.
|
||||||
|
|
||||||
|
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
|
||||||
|
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
|
||||||
|
blocks so an event spanning a whole block cannot raise its own floor.
|
||||||
|
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
|
||||||
|
is relative to "how loud it normally is right now"."""
|
||||||
|
n = len(db_values)
|
||||||
|
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
|
||||||
|
floors = []
|
||||||
|
for i in range(0, n, block):
|
||||||
|
chunk = sorted(db_values[i:i + block])
|
||||||
|
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
|
||||||
|
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
|
||||||
|
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||||
|
margin_db: float, min_gap: float = MIN_GAP_SECONDS,
|
||||||
|
min_duration: float = MIN_DURATION_SECONDS) -> list:
|
||||||
|
"""Sections whose level rises at least margin_db above the local noise
|
||||||
|
floor. Each section carries a 'score': its peak dB above the floor,
|
||||||
|
capped by the sharpest rise observed within ONSET_SECONDS. Real events
|
||||||
|
(voices, impacts, barks) have steep onsets, so their cap equals their
|
||||||
|
peak; a swell that drifts up slower than the noise-floor blocks can track
|
||||||
|
(wind, a distant approaching car) still flags but scores near zero, so
|
||||||
|
score-ranked review (chips, U/I highlights) surfaces events first.
|
||||||
|
|
||||||
|
Sections shorter than min_duration (after min_gap merging) are discarded:
|
||||||
|
without this, every isolated 100 ms window that pops above the floor — a
|
||||||
|
click, a single raindrop — becomes its own section and a day can drown in
|
||||||
|
thousands of sub-second clips."""
|
||||||
|
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
|
||||||
|
floor = _noise_floor_db(db, window_dur)
|
||||||
|
min_db = 20 * math.log10(MIN_RMS)
|
||||||
|
onset_win = max(1, int(round(ONSET_SECONDS / window_dur)))
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
start_t = None
|
||||||
|
last_loud_t = None
|
||||||
|
peak = 0.0
|
||||||
|
onset = 0.0
|
||||||
|
|
||||||
|
def _score():
|
||||||
|
return round(max(0.0, min(peak, onset)), 1)
|
||||||
|
|
||||||
|
for i, d in enumerate(db):
|
||||||
|
t = i * window_dur
|
||||||
|
floor_eff = max(floor[i], min_db)
|
||||||
|
if d >= floor_eff + margin_db:
|
||||||
|
if start_t is None:
|
||||||
|
start_t = t
|
||||||
|
peak = 0.0
|
||||||
|
onset = 0.0
|
||||||
|
last_loud_t = t
|
||||||
|
peak = max(peak, d - floor_eff)
|
||||||
|
# Rise within the onset span; a section starting before the file
|
||||||
|
# has history is measured against the floor instead (an event cut
|
||||||
|
# off by a file split must not be punished as a swell).
|
||||||
|
rise = d - db[i - onset_win] if i >= onset_win else d - floor_eff
|
||||||
|
onset = max(onset, rise)
|
||||||
|
else:
|
||||||
|
if start_t is not None and (t - last_loud_t) > min_gap:
|
||||||
|
end_t = last_loud_t + window_dur
|
||||||
|
if end_t - start_t >= min_duration - 1e-9:
|
||||||
|
sections.append({'start': round(start_t, 1),
|
||||||
|
'end': round(end_t, 1),
|
||||||
|
'score': _score()})
|
||||||
|
start_t = None
|
||||||
|
last_loud_t = None
|
||||||
|
|
||||||
|
# A section still open at EOF ends at its last loud window, exactly like
|
||||||
|
# every section closed inside the loop — not at the file duration, which
|
||||||
|
# used to stretch it by up to min_gap of trailing silence and put that
|
||||||
|
# silence into the clip served for it.
|
||||||
|
if start_t is not None and (last_loud_t + window_dur - start_t) >= min_duration - 1e-9:
|
||||||
|
sections.append({'start': round(start_t, 1),
|
||||||
|
'end': round(min(last_loud_t + window_dur, duration), 1),
|
||||||
|
'score': _score()})
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _package_result(rms_values: list, framerate: int, n_frames: int,
|
||||||
|
window_samples: int, margin_db: float,
|
||||||
|
min_gap: float = MIN_GAP_SECONDS,
|
||||||
|
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
||||||
|
window_dur = window_samples / framerate
|
||||||
|
duration = n_frames / framerate
|
||||||
|
|
||||||
|
# Note: no RMS data is returned — the UI is screen-reader oriented and
|
||||||
|
# draws no waveform, so sections + duration is all it needs. The window
|
||||||
|
# length went the same way: it only ever existed to place RMS bars.
|
||||||
|
return {
|
||||||
|
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
|
||||||
|
'duration': round(duration, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||||||
|
margin_db: float = MARGIN_DB,
|
||||||
|
min_gap: float = MIN_GAP_SECONDS,
|
||||||
|
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
||||||
|
try:
|
||||||
|
with wave.open(str(path), 'rb') as wf:
|
||||||
|
channels = wf.getnchannels()
|
||||||
|
sampwidth = wf.getsampwidth()
|
||||||
|
framerate = wf.getframerate()
|
||||||
|
n_frames = wf.getnframes()
|
||||||
|
# _compute_rms_windows_wav decodes as int16 unconditionally, so an
|
||||||
|
# 8- or 24-bit file would yield garbage RMS and therefore garbage
|
||||||
|
# sections — silently. The recorder only writes 16-bit, but --dir
|
||||||
|
# can point at any directory.
|
||||||
|
if sampwidth != 2:
|
||||||
|
return {'error': f'only 16-bit WAV files can be analysed '
|
||||||
|
f'(this one is {sampwidth * 8}-bit)'}
|
||||||
|
rms_values = list(_compute_rms_windows_wav(
|
||||||
|
wf, channels, sampwidth, framerate, window_samples))
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||||||
|
margin_db: float = MARGIN_DB,
|
||||||
|
min_gap: float = MIN_GAP_SECONDS,
|
||||||
|
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
||||||
|
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
|
||||||
|
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
|
||||||
|
return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sf.SoundFile(path) as f:
|
||||||
|
framerate = f.samplerate
|
||||||
|
channels = f.channels
|
||||||
|
n_frames = len(f)
|
||||||
|
|
||||||
|
rms_values = []
|
||||||
|
while True:
|
||||||
|
frames = f.read(window_samples, dtype='float32', always_2d=True)
|
||||||
|
if len(frames) == 0:
|
||||||
|
break
|
||||||
|
mono = frames.mean(axis=1) if channels > 1 else frames[:, 0]
|
||||||
|
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
|
||||||
|
rms_values.append(round(rms, 5))
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Shared test setup.
|
||||||
|
|
||||||
|
Puts the repo root on sys.path so `import isr`, `import web` and
|
||||||
|
`import isrweb.*` work regardless of where pytest is invoked from. This used
|
||||||
|
to be an inline sys.path.insert at the top of test_isr.py, which only
|
||||||
|
happened to work for the other test modules because that file imported first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def write_wav():
|
||||||
|
"""Factory for small 16-bit WAV files. Returns the path written."""
|
||||||
|
def _write(path, seconds=2, framerate=8000, channels=1, sampwidth=2,
|
||||||
|
frames=None):
|
||||||
|
with wave.open(str(path), 'wb') as wf:
|
||||||
|
wf.setnchannels(channels)
|
||||||
|
wf.setsampwidth(sampwidth)
|
||||||
|
wf.setframerate(framerate)
|
||||||
|
wf.writeframes(frames if frames is not None
|
||||||
|
else b'\x00\x00' * (seconds * framerate * channels))
|
||||||
|
return path
|
||||||
|
return _write
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Tests for the filename clock in isrweb/audiofile.py.
|
||||||
|
|
||||||
|
Recordings are named for their *start* time, and that name is the only
|
||||||
|
reliable time anchor the archive has — mtime drifts to the last write.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from isrweb.audiofile import _cut_filename, _recording_start
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
+181
-47
@@ -6,6 +6,7 @@ Run with: pytest tests/
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
import time
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -30,14 +31,10 @@ def fixed_clock(dt: datetime):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Import the module under test
|
# Import the module under test (sys.path is set up in conftest.py)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import sys
|
import isr
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
import isr # noqa: E402 (must come after sys.path manipulation)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -50,15 +47,12 @@ class TestAudioSystemFindDevice:
|
|||||||
def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem:
|
def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem:
|
||||||
system = isr.AudioSystem.__new__(isr.AudioSystem)
|
system = isr.AudioSystem.__new__(isr.AudioSystem)
|
||||||
system.logger = make_logger()
|
system.logger = make_logger()
|
||||||
system._backends = {}
|
system.available = True
|
||||||
system.list_all_devices = lambda: devices
|
system.list_devices = lambda: devices
|
||||||
return system
|
return system
|
||||||
|
|
||||||
def _dev(self, id_, name, *, is_default=False, is_monitor=False, backend="portaudio"):
|
def _dev(self, id_, name, *, is_default=False):
|
||||||
return isr.AudioDevice(
|
return isr.AudioDevice(id=id_, name=name, is_default=is_default)
|
||||||
id=id_, name=name, channels=2, sample_rate=44100,
|
|
||||||
backend=backend, is_default=is_default, is_monitor=is_monitor,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_default_returns_default_device(self):
|
def test_default_returns_default_device(self):
|
||||||
devices = [
|
devices = [
|
||||||
@@ -75,19 +69,9 @@ class TestAudioSystemFindDevice:
|
|||||||
result = system.find_device("default")
|
result = system.find_device("default")
|
||||||
assert result.id == "0"
|
assert result.id == "0"
|
||||||
|
|
||||||
def test_monitor_returns_monitor_device(self):
|
def test_default_with_no_cards_uses_the_alsa_default_pcm(self):
|
||||||
devices = [
|
system = self._make_system([])
|
||||||
self._dev("0", "Microphone"),
|
assert system.find_device("default").id == "default"
|
||||||
self._dev("1", "Monitor of Built-in Audio", is_monitor=True),
|
|
||||||
]
|
|
||||||
system = self._make_system(devices)
|
|
||||||
result = system.find_device("monitor")
|
|
||||||
assert result.id == "1"
|
|
||||||
|
|
||||||
def test_monitor_returns_none_when_no_monitor(self):
|
|
||||||
devices = [self._dev("0", "Mic")]
|
|
||||||
system = self._make_system(devices)
|
|
||||||
assert system.find_device("monitor") is None
|
|
||||||
|
|
||||||
def test_exact_id_match(self):
|
def test_exact_id_match(self):
|
||||||
devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")]
|
devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")]
|
||||||
@@ -104,19 +88,26 @@ class TestAudioSystemFindDevice:
|
|||||||
system = self._make_system(devices)
|
system = self._make_system(devices)
|
||||||
assert system.find_device("stereo").id == "0"
|
assert system.find_device("stereo").id == "0"
|
||||||
|
|
||||||
def test_returns_none_for_unknown_spec(self):
|
def test_exact_id_wins_over_partial_name(self):
|
||||||
devices = [self._dev("0", "Mic")]
|
# Two identical USB cards: both must stay reachable. An earlier
|
||||||
|
# dedup-by-name dropped the second one entirely.
|
||||||
|
devices = [self._dev("hw:1,0", "USB Audio"), self._dev("hw:2,0", "USB Audio")]
|
||||||
system = self._make_system(devices)
|
system = self._make_system(devices)
|
||||||
assert system.find_device("nonexistent_device_xyz") is None
|
assert system.find_device("hw:2,0").id == "hw:2,0"
|
||||||
|
|
||||||
def test_preferred_backend_filter_on_default(self):
|
def test_unknown_spec_becomes_a_literal_alsa_pcm_name(self):
|
||||||
devices = [
|
# This is how `device = shared_mic` (a dsnoop PCM from asound.conf)
|
||||||
self._dev("0", "Mic", is_default=True, backend="pulseaudio"),
|
# resolves — it is never listed by `arecord -l`.
|
||||||
self._dev("1", "Mic", is_default=True, backend="portaudio"),
|
devices = [self._dev("hw:0,0", "Mic")]
|
||||||
]
|
|
||||||
system = self._make_system(devices)
|
system = self._make_system(devices)
|
||||||
result = system.find_device("default", preferred_backend="portaudio")
|
assert system.find_device("shared_mic").id == "shared_mic"
|
||||||
assert result.backend == "portaudio"
|
|
||||||
|
def test_pcm_fallback_works_when_no_cards_are_enumerable(self):
|
||||||
|
# Regression: a `if not devices: return None` guard used to fire before
|
||||||
|
# the fallback, so shared_mic failed outright on a host with no
|
||||||
|
# enumerable hardware card.
|
||||||
|
system = self._make_system([])
|
||||||
|
assert system.find_device("shared_mic").id == "shared_mic"
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -423,6 +414,77 @@ class TestAudioFileWriter:
|
|||||||
assert info.samplerate == 44100
|
assert info.samplerate == 44100
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ALSAStream — capture liveness
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestALSAStreamLiveness:
|
||||||
|
"""check_alive() is what stops a dead arecord from being silently ignored."""
|
||||||
|
|
||||||
|
def _stream(self, poll_result=None) -> isr.ALSAStream:
|
||||||
|
device = isr.AudioDevice(
|
||||||
|
id="hw:0,0", name="Test",
|
||||||
|
)
|
||||||
|
s = isr.ALSAStream(device, 44100, 2, lambda data: None, make_logger())
|
||||||
|
s._process = MagicMock()
|
||||||
|
s._process.poll.return_value = poll_result
|
||||||
|
s._last_data = time.monotonic()
|
||||||
|
return s
|
||||||
|
|
||||||
|
def test_healthy_stream_does_not_raise(self):
|
||||||
|
self._stream().check_alive()
|
||||||
|
|
||||||
|
def test_raises_when_arecord_exited(self):
|
||||||
|
s = self._stream(poll_result=1)
|
||||||
|
with pytest.raises(RuntimeError, match="exited with code 1"):
|
||||||
|
s.check_alive()
|
||||||
|
|
||||||
|
def test_raises_when_no_audio_within_timeout(self):
|
||||||
|
# Process still up, but nothing has arrived — device yanked, or the
|
||||||
|
# dsnoop shared memory went away underneath us.
|
||||||
|
s = self._stream()
|
||||||
|
s._last_data = time.monotonic() - (isr.ALSAStream.SILENCE_TIMEOUT + 1)
|
||||||
|
with pytest.raises(RuntimeError, match="no audio"):
|
||||||
|
s.check_alive()
|
||||||
|
|
||||||
|
def test_read_loop_records_arrival_of_audio(self):
|
||||||
|
s = self._stream()
|
||||||
|
s._running = True
|
||||||
|
s._process.stdout.read.side_effect = [b"\x00" * 64, b""] # one chunk, then EOF
|
||||||
|
chunks = []
|
||||||
|
s.callback = chunks.append
|
||||||
|
|
||||||
|
s._read_loop()
|
||||||
|
|
||||||
|
assert chunks == [b"\x00" * 64]
|
||||||
|
assert s.received_audio is True
|
||||||
|
|
||||||
|
def test_read_loop_stops_at_eof_without_spinning(self):
|
||||||
|
s = self._stream()
|
||||||
|
s._running = True
|
||||||
|
s._process.stdout.read.return_value = b""
|
||||||
|
|
||||||
|
s._read_loop()
|
||||||
|
|
||||||
|
assert s._process.stdout.read.call_count == 1
|
||||||
|
assert s.received_audio is False
|
||||||
|
|
||||||
|
|
||||||
|
class _DeadStream:
|
||||||
|
"""Stand-in for a capture stream that opens but never delivers audio."""
|
||||||
|
|
||||||
|
received_audio = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_alive(self):
|
||||||
|
raise RuntimeError("arecord exited with code 1")
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# RecorderManager — config loading
|
# RecorderManager — config loading
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -453,7 +515,7 @@ format = ogg
|
|||||||
|
|
||||||
# Patch AudioSystem so it doesn't probe real hardware
|
# Patch AudioSystem so it doesn't probe real hardware
|
||||||
with patch.object(isr, "AudioSystem") as mock_as:
|
with patch.object(isr, "AudioSystem") as mock_as:
|
||||||
mock_as.return_value.available_backends = []
|
mock_as.return_value.available = False
|
||||||
mgr = isr.RecorderManager(str(cfg_path))
|
mgr = isr.RecorderManager(str(cfg_path))
|
||||||
|
|
||||||
assert len(mgr.recorders) == 1
|
assert len(mgr.recorders) == 1
|
||||||
@@ -467,7 +529,7 @@ format = ogg
|
|||||||
cfg_path.write_text(self._minimal_config(tmp_path))
|
cfg_path.write_text(self._minimal_config(tmp_path))
|
||||||
|
|
||||||
with patch.object(isr, "AudioSystem") as mock_as:
|
with patch.object(isr, "AudioSystem") as mock_as:
|
||||||
mock_as.return_value.available_backends = []
|
mock_as.return_value.available = False
|
||||||
mgr = isr.RecorderManager(str(cfg_path))
|
mgr = isr.RecorderManager(str(cfg_path))
|
||||||
|
|
||||||
rec = mgr.recorders[0]
|
rec = mgr.recorders[0]
|
||||||
@@ -494,7 +556,7 @@ split_minutes = 15
|
|||||||
cfg_path.write_text(config_text)
|
cfg_path.write_text(config_text)
|
||||||
|
|
||||||
with patch.object(isr, "AudioSystem") as mock_as:
|
with patch.object(isr, "AudioSystem") as mock_as:
|
||||||
mock_as.return_value.available_backends = []
|
mock_as.return_value.available = False
|
||||||
mgr = isr.RecorderManager(str(cfg_path))
|
mgr = isr.RecorderManager(str(cfg_path))
|
||||||
|
|
||||||
rec = mgr.recorders[0]
|
rec = mgr.recorders[0]
|
||||||
@@ -519,12 +581,63 @@ url = http://x.com/s2
|
|||||||
cfg_path.write_text(config_text)
|
cfg_path.write_text(config_text)
|
||||||
|
|
||||||
with patch.object(isr, "AudioSystem") as mock_as:
|
with patch.object(isr, "AudioSystem") as mock_as:
|
||||||
mock_as.return_value.available_backends = []
|
mock_as.return_value.available = False
|
||||||
mgr = isr.RecorderManager(str(cfg_path))
|
mgr = isr.RecorderManager(str(cfg_path))
|
||||||
|
|
||||||
assert len(mgr.recorders) == 1
|
assert len(mgr.recorders) == 1
|
||||||
assert mgr.recorders[0].name == "good_source"
|
assert mgr.recorders[0].name == "good_source"
|
||||||
|
|
||||||
|
def test_bad_integer_skips_only_that_source(self, tmp_path):
|
||||||
|
# A non-numeric split_minutes used to raise ValueError out of the
|
||||||
|
# config loop and abort the whole load, taking healthy sources with it.
|
||||||
|
log_file = str(tmp_path / "test.log")
|
||||||
|
config_text = f"""
|
||||||
|
[general]
|
||||||
|
log_level = WARNING
|
||||||
|
log_file = {log_file}
|
||||||
|
|
||||||
|
[broken]
|
||||||
|
type = stream
|
||||||
|
url = http://x.com/s
|
||||||
|
split_minutes = sixty
|
||||||
|
|
||||||
|
[good_source]
|
||||||
|
type = stream
|
||||||
|
url = http://x.com/s2
|
||||||
|
"""
|
||||||
|
cfg_path = tmp_path / "config.ini"
|
||||||
|
cfg_path.write_text(config_text)
|
||||||
|
|
||||||
|
with patch.object(isr, "AudioSystem") as mock_as:
|
||||||
|
mock_as.return_value.available = False
|
||||||
|
mgr = isr.RecorderManager(str(cfg_path))
|
||||||
|
|
||||||
|
assert [r.name for r in mgr.recorders] == ["good_source"]
|
||||||
|
|
||||||
|
def test_zero_split_minutes_is_clamped(self, tmp_path):
|
||||||
|
# split_minutes = 0 reached get_next_split_time() and divided by zero.
|
||||||
|
log_file = str(tmp_path / "test.log")
|
||||||
|
config_text = f"""
|
||||||
|
[general]
|
||||||
|
log_level = WARNING
|
||||||
|
log_file = {log_file}
|
||||||
|
|
||||||
|
[zero_split]
|
||||||
|
type = stream
|
||||||
|
url = http://x.com/s
|
||||||
|
split_minutes = 0
|
||||||
|
"""
|
||||||
|
cfg_path = tmp_path / "config.ini"
|
||||||
|
cfg_path.write_text(config_text)
|
||||||
|
|
||||||
|
with patch.object(isr, "AudioSystem") as mock_as:
|
||||||
|
mock_as.return_value.available = False
|
||||||
|
mgr = isr.RecorderManager(str(cfg_path))
|
||||||
|
|
||||||
|
rec = mgr.recorders[0]
|
||||||
|
assert rec.split_duration == 1
|
||||||
|
rec.get_next_split_time() # must not raise ZeroDivisionError
|
||||||
|
|
||||||
def test_missing_config_file_exits(self, tmp_path):
|
def test_missing_config_file_exits(self, tmp_path):
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
# main() calls sys.exit(1) when the file is missing
|
# main() calls sys.exit(1) when the file is missing
|
||||||
@@ -571,22 +684,18 @@ class TestStreamRecorderRecord:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
class TestSoundcardRecorder:
|
class TestSoundcardRecorder:
|
||||||
def _make_device(self, backend="portaudio") -> isr.AudioDevice:
|
def _make_device(self) -> isr.AudioDevice:
|
||||||
return isr.AudioDevice(
|
return isr.AudioDevice(id="0", name="Test Device")
|
||||||
id="0", name="Test Device", channels=2,
|
|
||||||
sample_rate=44100, backend=backend,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem:
|
def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem:
|
||||||
system = MagicMock(spec=isr.AudioSystem)
|
system = MagicMock(spec=isr.AudioSystem)
|
||||||
system.available_backends = [device.backend]
|
system.available = True
|
||||||
system.find_device.return_value = device
|
system.find_device.return_value = device
|
||||||
return system
|
return system
|
||||||
|
|
||||||
def test_wav_file_written_correctly(self, tmp_path):
|
def test_wav_file_written_correctly(self, tmp_path):
|
||||||
device = self._make_device()
|
device = self._make_device()
|
||||||
audio_system = self._make_audio_system(device)
|
audio_system = self._make_audio_system(device)
|
||||||
audio_system.get_backend.return_value = MagicMock()
|
|
||||||
|
|
||||||
cfg = {
|
cfg = {
|
||||||
"device": "default",
|
"device": "default",
|
||||||
@@ -631,6 +740,31 @@ class TestSoundcardRecorder:
|
|||||||
with pytest.raises(ImportError, match="soundfile"):
|
with pytest.raises(ImportError, match="soundfile"):
|
||||||
rec._open_output_file()
|
rec._open_output_file()
|
||||||
|
|
||||||
|
def test_dead_capture_gives_up_instead_of_recording_nothing(self, tmp_path):
|
||||||
|
# A capture that opens but never delivers audio must exhaust
|
||||||
|
# max_retries and stop. Before check_alive() existed, record() sat in
|
||||||
|
# its inner loop forever, rolling over to a new split file every
|
||||||
|
# split_minutes and writing zero bytes into each one.
|
||||||
|
device = self._make_device()
|
||||||
|
audio_system = self._make_audio_system(device)
|
||||||
|
audio_system.open_stream.return_value = _DeadStream()
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
"device": "default",
|
||||||
|
"sample_rate": 44100,
|
||||||
|
"channels": 2,
|
||||||
|
"format": "wav",
|
||||||
|
"output_directory": str(tmp_path),
|
||||||
|
"split_minutes": 60,
|
||||||
|
"max_retries": 2,
|
||||||
|
"retry_delay_seconds": 0,
|
||||||
|
}
|
||||||
|
rec = isr.SoundcardRecorder("sc_dead", cfg, make_logger(), audio_system=audio_system)
|
||||||
|
with patch.object(isr.time, "sleep"): # don't pay the 0.5 s poll interval
|
||||||
|
rec.record()
|
||||||
|
|
||||||
|
assert audio_system.open_stream.call_count == cfg["max_retries"]
|
||||||
|
|
||||||
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
|
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
|
||||||
def test_flac_file_written_correctly(self, tmp_path):
|
def test_flac_file_written_correctly(self, tmp_path):
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Tests for the adaptive loud-section detector in isrweb/loudness.py."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import wave
|
||||||
|
|
||||||
|
from isrweb.loudness import _loud_sections, _noise_floor_db, analyze_wav
|
||||||
|
|
||||||
|
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_section_open_at_eof_ends_at_last_loud_window():
|
||||||
|
# Burst ends 1 s before EOF — less than min_gap, so the section is still
|
||||||
|
# open when the windows run out. It must end where the sound ended, not at
|
||||||
|
# the file duration; otherwise the clip carries a second of trailing
|
||||||
|
# silence and disagrees with every section closed mid-file.
|
||||||
|
rms = [0.002] * 1200
|
||||||
|
rms[1180:1190] = [0.05] * 10 # loud 118.0 → 119.0, file is 120.0 s
|
||||||
|
sections = _run(rms)
|
||||||
|
assert len(sections) == 1
|
||||||
|
assert sections[0]['start'] == 118.0
|
||||||
|
assert sections[0]['end'] == 119.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_section_running_to_the_very_end_is_capped_at_duration():
|
||||||
|
rms = [0.002] * 1200
|
||||||
|
rms[1190:1200] = [0.05] * 10 # loud right up to EOF
|
||||||
|
sections = _run(rms)
|
||||||
|
assert len(sections) == 1
|
||||||
|
assert sections[0]['end'] == 120.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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_16_bit_wav_is_rejected_rather_than_misread(tmp_path, write_wav):
|
||||||
|
# RMS windowing decodes as int16 regardless of the declared sample width,
|
||||||
|
# so an 8-bit file used to produce meaningless sections with no warning.
|
||||||
|
path = tmp_path / 'eight_bit.wav'
|
||||||
|
with wave.open(str(path), 'wb') as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(1)
|
||||||
|
wf.setframerate(8000)
|
||||||
|
wf.writeframes(b'\x80' * 8000)
|
||||||
|
result = analyze_wav(path)
|
||||||
|
assert 'error' in result
|
||||||
|
assert '16-bit' in result['error']
|
||||||
|
|
||||||
|
|
||||||
|
def test_16_bit_wav_still_analyses(tmp_path, write_wav):
|
||||||
|
path = tmp_path / 'ok.wav'
|
||||||
|
write_wav(path)
|
||||||
|
assert 'error' not in analyze_wav(path)
|
||||||
|
|
||||||
|
|
||||||
+99
-144
@@ -1,162 +1,117 @@
|
|||||||
"""Tests for the adaptive loud-section detector in web.py."""
|
"""Tests for the HTTP layer and file listing in web.py."""
|
||||||
|
|
||||||
import math
|
import io
|
||||||
|
|
||||||
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start
|
import web
|
||||||
|
|
||||||
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):
|
def test_listing_is_ordered_by_the_filename_clock_not_mtime(tmp_path, write_wav):
|
||||||
duration = len(rms) * WINDOW_DUR
|
# mtime is the last write (≈ the end of the recording) and drifts from the
|
||||||
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
|
# name on any copy or round-trip. Here the older recording is touched last.
|
||||||
|
import os
|
||||||
|
early, late = tmp_path / '20260523_080000.wav', tmp_path / '20260523_090000.wav'
|
||||||
|
write_wav(early)
|
||||||
|
write_wav(late)
|
||||||
|
os.utime(late, (1000, 1000)) # newest recording, oldest mtime
|
||||||
|
|
||||||
|
names = [f['name'] for f in web.list_files(str(tmp_path))]
|
||||||
def test_burst_above_quiet_floor_is_detected():
|
assert names == ['20260523_090000.wav', '20260523_080000.wav']
|
||||||
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
|
# /api/clip error handling
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_recording_start_parses_standard_name():
|
class _OfflineHandler(web._Handler):
|
||||||
from datetime import datetime
|
"""A _Handler with no socket, for driving handler methods directly."""
|
||||||
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
|
|
||||||
|
def __init__(self, recordings_dir):
|
||||||
|
self.recordings_dir = str(recordings_dir)
|
||||||
|
self.analyses_dir = str(recordings_dir)
|
||||||
|
self.command = 'GET' # _send skips the body on HEAD
|
||||||
|
self.wfile = io.BytesIO()
|
||||||
|
self.close_connection = False
|
||||||
|
self.codes = []
|
||||||
|
|
||||||
|
def send_response(self, code):
|
||||||
|
self.codes.append(code)
|
||||||
|
|
||||||
|
def send_header(self, key, value):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_recording_start_rejects_nonstandard_name():
|
def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path, write_wav):
|
||||||
assert _recording_start("radio1_20260523") is None
|
# Once the audio headers are out, an error body would be a second response
|
||||||
assert _recording_start("notes") is None
|
# on a keep-alive connection that already promised audio/wav plus an exact
|
||||||
|
# Content-Length — desyncing every later request on that connection.
|
||||||
|
write_wav(tmp_path / 'rec.wav')
|
||||||
|
h = _OfflineHandler(tmp_path)
|
||||||
|
|
||||||
|
def boom(path, start, end):
|
||||||
|
h._send_clip_headers(b'HDR', 10, 1, 2)
|
||||||
|
raise RuntimeError('decode exploded')
|
||||||
|
|
||||||
|
h._clip_wav = boom
|
||||||
|
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
|
||||||
|
|
||||||
|
assert h.close_connection is True
|
||||||
|
assert h.codes == [200] # no second status line
|
||||||
|
assert b'error' not in h.wfile.getvalue() # no JSON body
|
||||||
|
|
||||||
|
|
||||||
def test_cut_filename_uses_wall_clock_span():
|
def test_clip_failure_before_headers_still_reports_json(tmp_path, write_wav):
|
||||||
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
|
write_wav(tmp_path / 'rec.wav')
|
||||||
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
|
h = _OfflineHandler(tmp_path)
|
||||||
assert name == "20260523_22-31-30_22-32-30.flac"
|
|
||||||
|
def boom(path, start, end):
|
||||||
|
raise RuntimeError('could not open')
|
||||||
|
|
||||||
|
h._clip_wav = boom
|
||||||
|
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
|
||||||
|
|
||||||
|
assert h.close_connection is False
|
||||||
|
assert h.codes == [500]
|
||||||
|
assert b'could not open' in h.wfile.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def test_cut_filename_rolls_over_the_hour():
|
# ---------------------------------------------------------------------------
|
||||||
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
|
# Browser UI static files (webui/)
|
||||||
assert name == "20260523_22-59-50_23-01-01.wav"
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ui_serves_index_and_modules_with_their_types(tmp_path):
|
||||||
|
h = _OfflineHandler(tmp_path)
|
||||||
|
types = []
|
||||||
|
h.send_header = lambda key, value: types.append(value) if key == 'Content-Type' else None
|
||||||
|
|
||||||
|
h._ui('index.html')
|
||||||
|
h._ui('js/main.js')
|
||||||
|
h._ui('style.css')
|
||||||
|
|
||||||
|
assert h.codes == [200, 200, 200]
|
||||||
|
assert types == ['text/html; charset=utf-8', 'text/javascript; charset=utf-8',
|
||||||
|
'text/css; charset=utf-8']
|
||||||
|
|
||||||
|
|
||||||
def test_cut_filename_falls_back_for_nonstandard_name():
|
def test_ui_refuses_paths_outside_webui_and_unlisted_types(tmp_path):
|
||||||
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
|
h = _OfflineHandler(tmp_path)
|
||||||
assert name == "mixtape_cut_740s-750s.mp3"
|
|
||||||
|
h._ui('../web.py') # escapes webui/
|
||||||
|
h._ui('..\web.py')
|
||||||
|
h._ui('js') # a directory
|
||||||
|
h._ui('missing.js')
|
||||||
|
|
||||||
|
assert h.codes == [404, 404, 404, 404]
|
||||||
|
assert b'import' not in h.wfile.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_ui_module_import_resolves():
|
||||||
|
# No JS toolchain on the dev box: at least catch a renamed module or a
|
||||||
|
# misspelt relative import before the browser does.
|
||||||
|
import re
|
||||||
|
js = web.WEBUI_DIR / 'js'
|
||||||
|
for mod in js.glob('*.js'):
|
||||||
|
for target in re.findall(r"from '\./([\w.]+)'", mod.read_text(encoding='utf-8')):
|
||||||
|
assert (js / target).is_file(), f'{mod.name} imports missing {target}'
|
||||||
|
|||||||
@@ -15,23 +15,22 @@ Usage:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import numpy as np
|
import numpy # noqa: F401 — only the availability flag is used here,
|
||||||
NUMPY_AVAILABLE = True
|
NUMPY_AVAILABLE = True # to refuse FLAC work rather than fail halfway
|
||||||
except ImportError:
|
except ImportError:
|
||||||
NUMPY_AVAILABLE = False
|
NUMPY_AVAILABLE = False
|
||||||
|
|
||||||
@@ -41,421 +40,22 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
SOUNDFILE_AVAILABLE = False
|
SOUNDFILE_AVAILABLE = False
|
||||||
|
|
||||||
|
# The detector and the byte-level format helpers live beside this file. Both
|
||||||
|
# are leaves: they import nothing from here, so `web.py -> {loudness,
|
||||||
|
# audiofile}` is the only import edge and there are no cycles.
|
||||||
|
from isrweb.audiofile import (AUDIO_EXTENSIONS, MIME_TYPES, _cut_filename,
|
||||||
|
_get_audio_duration, _live_flac_header,
|
||||||
|
_live_wav_header, _recording_start, _wav_header)
|
||||||
|
from isrweb.loudness import (DETECTOR_VERSION, MARGIN_DB, MIN_DURATION_SECONDS,
|
||||||
|
MIN_GAP_SECONDS, analyze_flac, analyze_wav)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
|
|
||||||
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
|
|
||||||
MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
|
|
||||||
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
|
|
||||||
MIN_DURATION_SECONDS = 0.5 # discard loud sections shorter than this
|
|
||||||
|
|
||||||
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
|
|
||||||
NOISE_PERCENTILE = 20 # percentile of windowed dB levels taken as the floor
|
|
||||||
MIN_RMS = 0.002 # ≈ −54 dBFS; the floor never drops below this, so
|
|
||||||
# digital silence does not make every tiny sound loud
|
|
||||||
ONSET_SECONDS = 0.5 # a section's score is capped by its sharpest dB rise
|
|
||||||
# within this span, so slow swells rank low
|
|
||||||
|
|
||||||
# Bumped whenever section detection/scoring changes in a way that makes old
|
|
||||||
# cached results wrong (not just differently parameterised). Caches written
|
|
||||||
# with another version never match and are recomputed on the next analyse.
|
|
||||||
DETECTOR_VERSION = 2
|
|
||||||
|
|
||||||
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
|
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
|
||||||
|
|
||||||
# Recording filenames encode the start time as this strftime format (kept in
|
|
||||||
# sync with isr.FILENAME_FORMAT). It is the authoritative recording start and
|
|
||||||
# the only reliable clock anchor — mtime drifts to the last write, so cut clip
|
|
||||||
# names and the displayed date are both derived from this.
|
|
||||||
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
|
|
||||||
|
|
||||||
MIME_TYPES = {
|
|
||||||
'.wav': 'audio/wav',
|
|
||||||
'.mp3': 'audio/mpeg',
|
|
||||||
'.ogg': 'audio/ogg',
|
|
||||||
'.flac': 'audio/flac',
|
|
||||||
'.aac': 'audio/aac',
|
|
||||||
'.opus': 'audio/ogg',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Audio analysis helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _recording_start(stem: str):
|
|
||||||
"""Parse the recording start time encoded in a filename stem.
|
|
||||||
|
|
||||||
Returns a datetime, or None if the stem is not in FILENAME_FORMAT
|
|
||||||
(e.g. a manually renamed file). strptime ignores any extension because
|
|
||||||
callers pass Path.stem.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return datetime.strptime(stem, FILENAME_FORMAT)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _cut_filename(stem: str, ext: str, start: float, end: float) -> str:
|
|
||||||
"""Name a cut by the real wall-clock span it covers.
|
|
||||||
|
|
||||||
For a recording that started at 22:00:00, a 22:31:30→22:32:30 slice
|
|
||||||
(start=1890, end=1950) becomes ``20260523_22-31-30_22-32-30.flac``.
|
|
||||||
Falls back to the source stem plus second offsets when the filename is
|
|
||||||
not in FILENAME_FORMAT (e.g. a manually renamed recording).
|
|
||||||
"""
|
|
||||||
started = _recording_start(stem)
|
|
||||||
if started is None:
|
|
||||||
return f'{stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
|
||||||
cut_start = started + timedelta(seconds=start)
|
|
||||||
cut_end = started + timedelta(seconds=end)
|
|
||||||
return f'{cut_start:%Y%m%d}_{cut_start:%H-%M-%S}_{cut_end:%H-%M-%S}{ext}'
|
|
||||||
|
|
||||||
|
|
||||||
def _live_wav_header(path: Path, size: int):
|
|
||||||
"""Return the WAV header (through the 'data' chunk header) with RIFF and
|
|
||||||
data sizes rewritten to match the current file size, or None.
|
|
||||||
|
|
||||||
While a WAV file is still being recorded its header claims ~0 frames, so
|
|
||||||
browsers show no duration and refuse to seek. Serving a header patched to
|
|
||||||
the bytes recorded so far fixes both; the patch is the same length as the
|
|
||||||
original header, so all byte offsets and Range math stay valid.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(path, 'rb') as fh:
|
|
||||||
hdr = fh.read(512)
|
|
||||||
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
|
|
||||||
return None
|
|
||||||
pos = 12
|
|
||||||
while pos + 8 <= len(hdr):
|
|
||||||
chunk_id = hdr[pos:pos + 4]
|
|
||||||
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
|
|
||||||
if chunk_id == b'data':
|
|
||||||
data_off = pos + 8
|
|
||||||
patched = bytearray(hdr[:data_off])
|
|
||||||
patched[4:8] = (size - 8).to_bytes(4, 'little')
|
|
||||||
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
|
|
||||||
return bytes(patched)
|
|
||||||
pos += 8 + chunk_size + (chunk_size & 1)
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# CRC-8 (poly 0x07) used by FLAC frame headers
|
|
||||||
_CRC8_TABLE = []
|
|
||||||
for _i in range(256):
|
|
||||||
_c = _i
|
|
||||||
for _ in range(8):
|
|
||||||
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
|
|
||||||
_CRC8_TABLE.append(_c)
|
|
||||||
|
|
||||||
_FLAC_BLOCKSIZES = {1: 192, 2: 576, 3: 1152, 4: 2304, 5: 4608, 8: 256, 9: 512,
|
|
||||||
10: 1024, 11: 2048, 12: 4096, 13: 8192, 14: 16384, 15: 32768}
|
|
||||||
|
|
||||||
|
|
||||||
def _crc8(data: bytes) -> int:
|
|
||||||
crc = 0
|
|
||||||
for b in data:
|
|
||||||
crc = _CRC8_TABLE[crc ^ b]
|
|
||||||
return crc
|
|
||||||
|
|
||||||
|
|
||||||
def _flac_coded_number(buf: bytes, pos: int):
|
|
||||||
"""Decode the UTF-8-style frame/sample number; returns (value, next_pos)."""
|
|
||||||
b0 = buf[pos]
|
|
||||||
if b0 < 0x80:
|
|
||||||
return b0, pos + 1
|
|
||||||
n, mask = 0, 0x40
|
|
||||||
while b0 & mask:
|
|
||||||
n += 1
|
|
||||||
mask >>= 1
|
|
||||||
if n < 1 or n > 6: # 10xxxxxx is not a valid leading byte
|
|
||||||
return None
|
|
||||||
val = b0 & (mask - 1)
|
|
||||||
for i in range(1, n + 1):
|
|
||||||
c = buf[pos + i]
|
|
||||||
if c & 0xC0 != 0x80:
|
|
||||||
return None
|
|
||||||
val = (val << 6) | (c & 0x3F)
|
|
||||||
return val, pos + 1 + n
|
|
||||||
|
|
||||||
|
|
||||||
def _flac_frame_samples(buf: bytes, pos: int, fixed_bs: int):
|
|
||||||
"""If a valid FLAC frame header starts at pos, return the stream sample
|
|
||||||
count through the end of that frame, else None. Validity is confirmed by
|
|
||||||
the header's CRC-8, so false sync matches in compressed data are rejected."""
|
|
||||||
try:
|
|
||||||
variable = buf[pos + 1] & 0x01
|
|
||||||
bs_code = buf[pos + 2] >> 4
|
|
||||||
sr_code = buf[pos + 2] & 0x0F
|
|
||||||
if bs_code == 0 or sr_code == 15 or buf[pos + 3] & 0x01:
|
|
||||||
return None
|
|
||||||
if (buf[pos + 3] >> 4) > 10: # reserved channel assignment
|
|
||||||
return None
|
|
||||||
coded = _flac_coded_number(buf, pos + 4)
|
|
||||||
if coded is None:
|
|
||||||
return None
|
|
||||||
val, p = coded
|
|
||||||
bs = _FLAC_BLOCKSIZES.get(bs_code)
|
|
||||||
if bs_code == 6:
|
|
||||||
bs = buf[p] + 1
|
|
||||||
p += 1
|
|
||||||
elif bs_code == 7:
|
|
||||||
bs = int.from_bytes(buf[p:p + 2], 'big') + 1
|
|
||||||
p += 2
|
|
||||||
if sr_code == 12:
|
|
||||||
p += 1
|
|
||||||
elif sr_code in (13, 14):
|
|
||||||
p += 2
|
|
||||||
if _crc8(buf[pos:p]) != buf[p]:
|
|
||||||
return None
|
|
||||||
if variable: # val is the frame's starting sample number
|
|
||||||
return val + (bs or 0)
|
|
||||||
return val * (fixed_bs or bs or 4096) + (bs or 0)
|
|
||||||
except IndexError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _live_flac_header(path: Path, size: int):
|
|
||||||
"""Return the first 26 bytes of a FLAC file with STREAMINFO total_samples
|
|
||||||
patched to the samples recorded so far, or None.
|
|
||||||
|
|
||||||
Like _live_wav_header, but FLAC duration cannot be derived from the byte
|
|
||||||
count (variable compression). Instead the sample count is parsed out of
|
|
||||||
the last frame header in the file tail — each FLAC frame carries its own
|
|
||||||
frame/sample number.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(path, 'rb') as fh:
|
|
||||||
head = fh.read(42)
|
|
||||||
if len(head) < 42 or head[:4] != b'fLaC':
|
|
||||||
return None
|
|
||||||
# STREAMINFO must be the first metadata block
|
|
||||||
if head[4] & 0x7F != 0 or int.from_bytes(head[5:8], 'big') != 34:
|
|
||||||
return None
|
|
||||||
fixed_bs = int.from_bytes(head[8:10], 'big')
|
|
||||||
|
|
||||||
tail_len = min(size, 65536)
|
|
||||||
fh.seek(size - tail_len)
|
|
||||||
buf = fh.read(tail_len)
|
|
||||||
|
|
||||||
samples = None
|
|
||||||
for i in range(len(buf) - 20, -1, -1):
|
|
||||||
if buf[i] == 0xFF and (buf[i + 1] & 0xFC) == 0xF8:
|
|
||||||
samples = _flac_frame_samples(buf, i, fixed_bs)
|
|
||||||
if samples:
|
|
||||||
break
|
|
||||||
if not samples:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Bytes 18-25 hold: sample rate (20 bits) | channels-1 (3) |
|
|
||||||
# bps-1 (5) | total_samples (36). Replace only the low 36 bits.
|
|
||||||
field = int.from_bytes(head[18:26], 'big')
|
|
||||||
field = (field & ~((1 << 36) - 1)) | min(samples, (1 << 36) - 1)
|
|
||||||
patched = bytearray(head[:26])
|
|
||||||
patched[18:26] = field.to_bytes(8, 'big')
|
|
||||||
return bytes(patched)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _wav_header(n_frames: int, channels: int, framerate: int, sampwidth: int) -> bytes:
|
|
||||||
"""Standard 44-byte PCM WAV header for a clip of known length."""
|
|
||||||
data_len = n_frames * channels * sampwidth
|
|
||||||
byte_rate = framerate * channels * sampwidth
|
|
||||||
return (b'RIFF' + (36 + data_len).to_bytes(4, 'little') + b'WAVE'
|
|
||||||
+ b'fmt ' + (16).to_bytes(4, 'little')
|
|
||||||
+ struct.pack('<HHIIHH', 1, channels, framerate, byte_rate,
|
|
||||||
channels * sampwidth, sampwidth * 8)
|
|
||||||
+ b'data' + data_len.to_bytes(4, 'little'))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_audio_duration(path: Path):
|
|
||||||
"""Return duration in seconds for any supported audio file, or None."""
|
|
||||||
ext = path.suffix.lower()
|
|
||||||
if ext == '.wav':
|
|
||||||
try:
|
|
||||||
with wave.open(str(path), 'rb') as wf:
|
|
||||||
return wf.getnframes() / wf.getframerate()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
|
|
||||||
try:
|
|
||||||
with sf.SoundFile(path) as f:
|
|
||||||
return round(len(f) / f.samplerate, 2)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
|
|
||||||
window_samples: int):
|
|
||||||
"""Yield rms_0_to_1 for every window in the open wave file."""
|
|
||||||
while True:
|
|
||||||
raw = wf.readframes(window_samples)
|
|
||||||
if not raw:
|
|
||||||
break
|
|
||||||
n_samp = len(raw) // (sampwidth * channels)
|
|
||||||
if n_samp == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
if NUMPY_AVAILABLE:
|
|
||||||
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
|
|
||||||
if channels > 1:
|
|
||||||
arr = arr.reshape(-1, channels).mean(axis=1)
|
|
||||||
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
|
|
||||||
else:
|
|
||||||
fmt = f'<{n_samp * channels}h'
|
|
||||||
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
|
|
||||||
mono = samples[::channels] if channels > 1 else samples
|
|
||||||
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
|
|
||||||
|
|
||||||
yield round(rms, 5)
|
|
||||||
|
|
||||||
|
|
||||||
def _noise_floor_db(db_values: list, window_dur: float) -> list:
|
|
||||||
"""Per-window background noise floor in dBFS.
|
|
||||||
|
|
||||||
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
|
|
||||||
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
|
|
||||||
blocks so an event spanning a whole block cannot raise its own floor.
|
|
||||||
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
|
|
||||||
is relative to "how loud it normally is right now"."""
|
|
||||||
n = len(db_values)
|
|
||||||
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
|
|
||||||
floors = []
|
|
||||||
for i in range(0, n, block):
|
|
||||||
chunk = sorted(db_values[i:i + block])
|
|
||||||
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
|
|
||||||
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
|
|
||||||
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
|
|
||||||
|
|
||||||
|
|
||||||
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
|
||||||
margin_db: float, min_gap: float = MIN_GAP_SECONDS,
|
|
||||||
min_duration: float = MIN_DURATION_SECONDS) -> list:
|
|
||||||
"""Sections whose level rises at least margin_db above the local noise
|
|
||||||
floor. Each section carries a 'score': its peak dB above the floor,
|
|
||||||
capped by the sharpest rise observed within ONSET_SECONDS. Real events
|
|
||||||
(voices, impacts, barks) have steep onsets, so their cap equals their
|
|
||||||
peak; a swell that drifts up slower than the noise-floor blocks can track
|
|
||||||
(wind, a distant approaching car) still flags but scores near zero, so
|
|
||||||
score-ranked review (chips, U/I highlights) surfaces events first.
|
|
||||||
|
|
||||||
Sections shorter than min_duration (after min_gap merging) are discarded:
|
|
||||||
without this, every isolated 100 ms window that pops above the floor — a
|
|
||||||
click, a single raindrop — becomes its own section and a day can drown in
|
|
||||||
thousands of sub-second clips."""
|
|
||||||
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
|
|
||||||
floor = _noise_floor_db(db, window_dur)
|
|
||||||
min_db = 20 * math.log10(MIN_RMS)
|
|
||||||
onset_win = max(1, int(round(ONSET_SECONDS / window_dur)))
|
|
||||||
|
|
||||||
sections = []
|
|
||||||
start_t = None
|
|
||||||
last_loud_t = None
|
|
||||||
peak = 0.0
|
|
||||||
onset = 0.0
|
|
||||||
|
|
||||||
def _score():
|
|
||||||
return round(max(0.0, min(peak, onset)), 1)
|
|
||||||
|
|
||||||
for i, d in enumerate(db):
|
|
||||||
t = i * window_dur
|
|
||||||
floor_eff = max(floor[i], min_db)
|
|
||||||
if d >= floor_eff + margin_db:
|
|
||||||
if start_t is None:
|
|
||||||
start_t = t
|
|
||||||
peak = 0.0
|
|
||||||
onset = 0.0
|
|
||||||
last_loud_t = t
|
|
||||||
peak = max(peak, d - floor_eff)
|
|
||||||
# Rise within the onset span; a section starting before the file
|
|
||||||
# has history is measured against the floor instead (an event cut
|
|
||||||
# off by a file split must not be punished as a swell).
|
|
||||||
rise = d - db[i - onset_win] if i >= onset_win else d - floor_eff
|
|
||||||
onset = max(onset, rise)
|
|
||||||
else:
|
|
||||||
if start_t is not None and (t - last_loud_t) > min_gap:
|
|
||||||
end_t = last_loud_t + window_dur
|
|
||||||
if end_t - start_t >= min_duration - 1e-9:
|
|
||||||
sections.append({'start': round(start_t, 1),
|
|
||||||
'end': round(end_t, 1),
|
|
||||||
'score': _score()})
|
|
||||||
start_t = None
|
|
||||||
last_loud_t = None
|
|
||||||
|
|
||||||
if start_t is not None and (last_loud_t + window_dur - start_t) >= min_duration - 1e-9:
|
|
||||||
sections.append({'start': round(start_t, 1), 'end': round(duration, 1),
|
|
||||||
'score': _score()})
|
|
||||||
|
|
||||||
return sections
|
|
||||||
|
|
||||||
|
|
||||||
def _package_result(rms_values: list, framerate: int, n_frames: int,
|
|
||||||
window_samples: int, margin_db: float,
|
|
||||||
min_gap: float = MIN_GAP_SECONDS,
|
|
||||||
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
|
||||||
window_dur = window_samples / framerate
|
|
||||||
duration = n_frames / framerate
|
|
||||||
|
|
||||||
# Note: no RMS data is returned — the UI is screen-reader oriented and
|
|
||||||
# draws no waveform, so sections + duration is all it needs.
|
|
||||||
return {
|
|
||||||
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
|
|
||||||
'duration': round(duration, 2),
|
|
||||||
'window': round(window_dur, 4),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|
||||||
margin_db: float = MARGIN_DB,
|
|
||||||
min_gap: float = MIN_GAP_SECONDS,
|
|
||||||
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
|
||||||
try:
|
|
||||||
with wave.open(str(path), 'rb') as wf:
|
|
||||||
channels = wf.getnchannels()
|
|
||||||
sampwidth = wf.getsampwidth()
|
|
||||||
framerate = wf.getframerate()
|
|
||||||
n_frames = wf.getnframes()
|
|
||||||
rms_values = list(_compute_rms_windows_wav(
|
|
||||||
wf, channels, sampwidth, framerate, window_samples))
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|
||||||
margin_db: float = MARGIN_DB,
|
|
||||||
min_gap: float = MIN_GAP_SECONDS,
|
|
||||||
min_duration: float = MIN_DURATION_SECONDS) -> dict:
|
|
||||||
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
|
|
||||||
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
|
|
||||||
return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with sf.SoundFile(path) as f:
|
|
||||||
framerate = f.samplerate
|
|
||||||
channels = f.channels
|
|
||||||
n_frames = len(f)
|
|
||||||
|
|
||||||
rms_values = []
|
|
||||||
while True:
|
|
||||||
frames = f.read(window_samples, dtype='float32', always_2d=True)
|
|
||||||
if len(frames) == 0:
|
|
||||||
break
|
|
||||||
mono = frames.mean(axis=1) if channels > 1 else frames[:, 0]
|
|
||||||
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
|
|
||||||
rms_values.append(round(rms, 5))
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Analysis cache helpers
|
# Analysis cache helpers
|
||||||
@@ -467,22 +67,31 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
|
|||||||
|
|
||||||
|
|
||||||
def _cached_analysis_params(cache_path: Path):
|
def _cached_analysis_params(cache_path: Path):
|
||||||
"""Read just detector/margin/min_gap/min_duration from a cache file
|
"""The params a cache file was written with, or None if it is unusable.
|
||||||
without parsing the whole JSON (the embedded result can be hundreds of
|
|
||||||
KB). Relies on the writer in _api_analyze putting these keys first.
|
Caches from another DETECTOR_VERSION — or so old they lack a key — return
|
||||||
Caches written by other detector versions (or so old they lack a key)
|
None and are recomputed on the next analyse.
|
||||||
simply never match and get recomputed on the next analyse."""
|
|
||||||
|
This used to regex four keys out of the first 256 bytes to avoid parsing
|
||||||
|
the whole file, which imposed a "these keys must be written first"
|
||||||
|
constraint on the writer. That was worth it when the cache embedded an
|
||||||
|
~800-point RMS preview and the full per-window list; those were dropped
|
||||||
|
when the waveforms went, so a cache is now a handful of floats and a
|
||||||
|
section list, and a plain load is both simpler and unbreakable by key
|
||||||
|
order."""
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'r', encoding='utf-8') as fh:
|
with open(cache_path, 'r', encoding='utf-8') as fh:
|
||||||
head = fh.read(256)
|
cached = json.load(fh)
|
||||||
except OSError:
|
except (OSError, ValueError):
|
||||||
return None
|
return None
|
||||||
m = re.search(r'"detector":\s*(\d+),\s*"margin":\s*([0-9.eE+-]+),'
|
if cached.get('detector') != DETECTOR_VERSION:
|
||||||
r'\s*"min_gap":\s*([0-9.eE+-]+),\s*"min_duration":\s*([0-9.eE+-]+)', head)
|
return None
|
||||||
if not m or int(m.group(1)) != DETECTOR_VERSION:
|
try:
|
||||||
|
return {'margin': float(cached['margin']),
|
||||||
|
'min_gap': float(cached['min_gap']),
|
||||||
|
'min_duration': float(cached['min_duration'])}
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return {'margin': float(m.group(2)), 'min_gap': float(m.group(3)),
|
|
||||||
'min_duration': float(m.group(4))}
|
|
||||||
|
|
||||||
|
|
||||||
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
||||||
@@ -553,8 +162,8 @@ def list_files(recordings_dir: str):
|
|||||||
|
|
||||||
# The recording start is encoded in the filename and is the true clock
|
# The recording start is encoded in the filename and is the true clock
|
||||||
# anchor; mtime is only a fallback for files not in FILENAME_FORMAT.
|
# anchor; mtime is only a fallback for files not in FILENAME_FORMAT.
|
||||||
started = _recording_start(path.stem)
|
started = _recording_start(path.stem) or datetime.fromtimestamp(stat.st_mtime)
|
||||||
date = (started or datetime.fromtimestamp(stat.st_mtime)).strftime('%Y-%m-%d %H:%M:%S')
|
date = started.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
# Skip reading partial headers for in-progress files — the WAV nframes
|
# Skip reading partial headers for in-progress files — the WAV nframes
|
||||||
# field and FLAC total_samples are both unfinalized while recording,
|
# field and FLAC total_samples are both unfinalized while recording,
|
||||||
@@ -571,7 +180,13 @@ def list_files(recordings_dir: str):
|
|||||||
'recording': is_active,
|
'recording': is_active,
|
||||||
})
|
})
|
||||||
|
|
||||||
files.sort(key=lambda f: f['mtime'], reverse=True)
|
# Sort by the recording start, not mtime: the filename is the clock, and
|
||||||
|
# everything the user sees — the DATE column, the day grouping, the date
|
||||||
|
# filters — is derived from it. mtime is the last write (≈ the end), so a
|
||||||
|
# file whose mtime drifted from its name (a copy, a restore, an ffmpeg
|
||||||
|
# round-trip) used to land at the wrong position within its own day.
|
||||||
|
# 'date' is fixed-width zero-padded, so string order is time order.
|
||||||
|
files.sort(key=lambda f: f['date'], reverse=True)
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -579,6 +194,19 @@ def list_files(recordings_dir: str):
|
|||||||
# HTTP handler
|
# HTTP handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _qs_float(qs, name: str, default: float, lo: float, hi: float) -> float:
|
||||||
|
"""A query-string float clamped to [lo, hi], or default if unparseable.
|
||||||
|
|
||||||
|
Analysis params are coupled across the CLI, /api/config, the UI inputs and
|
||||||
|
the cache keys — keeping the parse in one place means adding a fourth is a
|
||||||
|
one-line change here rather than another copy of this block.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return max(lo, min(hi, float(qs.get(name, [default])[0])))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
# Keep-alive: browsers reuse connections instead of a TCP handshake per
|
# Keep-alive: browsers reuse connections instead of a TCP handshake per
|
||||||
# request. Safe because every response sets Content-Length.
|
# request. Safe because every response sets Content-Length.
|
||||||
@@ -590,6 +218,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
min_gap: float = MIN_GAP_SECONDS
|
min_gap: float = MIN_GAP_SECONDS
|
||||||
min_duration: float = MIN_DURATION_SECONDS
|
min_duration: float = MIN_DURATION_SECONDS
|
||||||
|
|
||||||
|
# True once /api/clip has written its audio headers — see _api_clip
|
||||||
|
_clip_started: bool = False
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
p = parsed.path
|
p = parsed.path
|
||||||
@@ -598,13 +229,25 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._send(404, b'Not found', 'text/plain')
|
self._send(404, b'Not found', 'text/plain')
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
"""HEAD for the page and the audio files — some players probe before
|
||||||
|
fetching. Clips and cuts are generated on demand, so answering HEAD
|
||||||
|
for those would do all the work and throw the result away."""
|
||||||
|
p = urlparse(self.path).path
|
||||||
|
if p == '/' or p.startswith('/download/') or p.startswith('/stream/'):
|
||||||
|
self.do_GET()
|
||||||
|
else:
|
||||||
|
self._send(405, b'Method Not Allowed', 'text/plain')
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
p = parsed.path
|
p = parsed.path
|
||||||
|
|
||||||
if p == '/':
|
if p == '/':
|
||||||
self._html()
|
self._ui('index.html')
|
||||||
|
elif p.startswith('/ui/'):
|
||||||
|
self._ui(unquote(p[len('/ui/'):]))
|
||||||
elif p == '/api/files':
|
elif p == '/api/files':
|
||||||
self._api_files()
|
self._api_files()
|
||||||
elif p == '/api/analyze':
|
elif p == '/api/analyze':
|
||||||
@@ -626,8 +269,21 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._send(404, b'Not found', 'text/plain')
|
self._send(404, b'Not found', 'text/plain')
|
||||||
|
|
||||||
def _html(self):
|
def _ui(self, name: str):
|
||||||
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
|
"""Serve a file of the browser UI from webui/. Read per request: it is
|
||||||
|
a handful of small files, and edits show up on reload without a server
|
||||||
|
restart. Only the listed types are served, and the path must stay
|
||||||
|
inside webui/ (same guard as _safe_path, but for the UI folder)."""
|
||||||
|
try:
|
||||||
|
path = (WEBUI_DIR / name).resolve()
|
||||||
|
path.relative_to(WEBUI_DIR)
|
||||||
|
except ValueError:
|
||||||
|
path = None
|
||||||
|
ctype = _UI_TYPES.get(path.suffix) if path else None
|
||||||
|
if ctype is None or not path.is_file():
|
||||||
|
self._send(404, b'Not found', 'text/plain')
|
||||||
|
return
|
||||||
|
self._send(200, path.read_bytes(), ctype)
|
||||||
|
|
||||||
def _api_files(self):
|
def _api_files(self):
|
||||||
files = list_files(self.recordings_dir)
|
files = list_files(self.recordings_dir)
|
||||||
@@ -652,23 +308,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
if path is None:
|
if path is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
margin = _qs_float(qs, 'margin', self.margin_db, 1.0, 60.0)
|
||||||
margin = float(qs.get('margin', [self.margin_db])[0])
|
min_gap = _qs_float(qs, 'min_gap', self.min_gap, 0.0, 300.0)
|
||||||
margin = max(1.0, min(60.0, margin))
|
min_duration = _qs_float(qs, 'min_duration', self.min_duration, 0.0, 60.0)
|
||||||
except (ValueError, TypeError):
|
|
||||||
margin = self.margin_db
|
|
||||||
|
|
||||||
try:
|
|
||||||
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
|
|
||||||
min_gap = max(0.0, min(300.0, min_gap))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
min_gap = self.min_gap
|
|
||||||
|
|
||||||
try:
|
|
||||||
min_duration = float(qs.get('min_duration', [self.min_duration])[0])
|
|
||||||
min_duration = max(0.0, min(60.0, min_duration))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
min_duration = self.min_duration
|
|
||||||
|
|
||||||
if self._is_active(filename):
|
if self._is_active(filename):
|
||||||
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
|
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
|
||||||
@@ -707,8 +349,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = cache_path.with_suffix('.tmp')
|
tmp = cache_path.with_suffix('.tmp')
|
||||||
# detector, margin, min_gap and min_duration MUST stay first:
|
|
||||||
# _cached_analysis_params reads only the first 256 bytes of this file
|
|
||||||
tmp.write_text(json.dumps({'detector': DETECTOR_VERSION,
|
tmp.write_text(json.dumps({'detector': DETECTOR_VERSION,
|
||||||
'margin': margin, 'min_gap': min_gap,
|
'margin': margin, 'min_gap': min_gap,
|
||||||
'min_duration': min_duration, 'result': result}), 'utf-8')
|
'min_duration': min_duration, 'result': result}), 'utf-8')
|
||||||
@@ -728,20 +368,65 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
pass
|
pass
|
||||||
self._send(200, b'{"active":[]}', 'application/json')
|
self._send(200, b'{"active":[]}', 'application/json')
|
||||||
|
|
||||||
|
def _serve_bytes(self, path: Path, size: int, content_type: str,
|
||||||
|
prefix: bytes = b'', no_store: bool = False,
|
||||||
|
disposition: str = None):
|
||||||
|
"""Serve a file with HTTP Range support.
|
||||||
|
|
||||||
|
`prefix` replaces the first len(prefix) bytes of the file on the way
|
||||||
|
out — that is how in-progress recordings get a header patched to the
|
||||||
|
duration captured so far. It is the same length as the header it
|
||||||
|
replaces, so all offsets and Range math stay valid.
|
||||||
|
"""
|
||||||
|
range_header = self.headers.get('Range', '')
|
||||||
|
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
|
||||||
|
|
||||||
|
if m:
|
||||||
|
start = int(m.group(1))
|
||||||
|
end = int(m.group(2)) if m.group(2) else size - 1
|
||||||
|
end = min(end, size - 1)
|
||||||
|
if start > end or start >= size:
|
||||||
|
self._send(416, b'Range Not Satisfiable', 'text/plain')
|
||||||
|
return
|
||||||
|
length = end - start + 1
|
||||||
|
self.send_response(206)
|
||||||
|
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
|
||||||
|
else:
|
||||||
|
start, length = 0, size
|
||||||
|
self.send_response(200)
|
||||||
|
|
||||||
|
self.send_header('Content-Type', content_type)
|
||||||
|
self.send_header('Content-Length', str(length))
|
||||||
|
self.send_header('Accept-Ranges', 'bytes')
|
||||||
|
if disposition:
|
||||||
|
self.send_header('Content-Disposition', disposition)
|
||||||
|
if no_store:
|
||||||
|
self.send_header('Cache-Control', 'no-store')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
if self.command == 'HEAD':
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(path, 'rb') as fh:
|
||||||
|
sent = 0
|
||||||
|
if start < len(prefix):
|
||||||
|
head = prefix[start:start + length]
|
||||||
|
self.wfile.write(head)
|
||||||
|
sent = len(head)
|
||||||
|
if sent < length:
|
||||||
|
fh.seek(start + sent)
|
||||||
|
# Bound the copy: the file may grow while we serve it, and
|
||||||
|
# writing more than Content-Length desyncs keep-alive.
|
||||||
|
self._copy_to_response(fh, length - sent)
|
||||||
|
|
||||||
def _download(self, filename: str):
|
def _download(self, filename: str):
|
||||||
path = self._safe_path(filename)
|
path = self._safe_path(filename)
|
||||||
if path is None:
|
if path is None:
|
||||||
return
|
return
|
||||||
|
# Range support matters here too: these files run to hundreds of MB
|
||||||
size = path.stat().st_size
|
# and an interrupted download would otherwise have to restart.
|
||||||
self.send_response(200)
|
self._serve_bytes(path, path.stat().st_size, 'application/octet-stream',
|
||||||
self.send_header('Content-Type', 'application/octet-stream')
|
disposition=f'attachment; filename="{path.name}"')
|
||||||
self.send_header('Content-Disposition', f'attachment; filename="{path.name}"')
|
|
||||||
self.send_header('Content-Length', str(size))
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
with open(path, 'rb') as fh:
|
|
||||||
self._copy_to_response(fh, size)
|
|
||||||
|
|
||||||
def _stream(self, filename: str):
|
def _stream(self, filename: str):
|
||||||
"""Serve audio for inline playback with HTTP Range support.
|
"""Serve audio for inline playback with HTTP Range support.
|
||||||
@@ -766,54 +451,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
elif ext == '.flac':
|
elif ext == '.flac':
|
||||||
prefix = _live_flac_header(path, size) or b''
|
prefix = _live_flac_header(path, size) or b''
|
||||||
|
|
||||||
range_header = self.headers.get('Range', '')
|
self._serve_bytes(path, size, content_type, prefix=prefix, no_store=is_active)
|
||||||
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
|
|
||||||
|
|
||||||
if m:
|
|
||||||
start = int(m.group(1))
|
|
||||||
end = int(m.group(2)) if m.group(2) else size - 1
|
|
||||||
end = min(end, size - 1)
|
|
||||||
if start > end or start >= size:
|
|
||||||
self._send(416, b'Range Not Satisfiable', 'text/plain')
|
|
||||||
return
|
|
||||||
length = end - start + 1
|
|
||||||
|
|
||||||
self.send_response(206)
|
|
||||||
self.send_header('Content-Type', content_type)
|
|
||||||
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
|
|
||||||
self.send_header('Content-Length', str(length))
|
|
||||||
self.send_header('Accept-Ranges', 'bytes')
|
|
||||||
if is_active:
|
|
||||||
self.send_header('Cache-Control', 'no-store')
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
with open(path, 'rb') as fh:
|
|
||||||
sent = 0
|
|
||||||
if start < len(prefix):
|
|
||||||
head = prefix[start:start + length]
|
|
||||||
self.wfile.write(head)
|
|
||||||
sent = len(head)
|
|
||||||
if sent < length:
|
|
||||||
fh.seek(start + sent)
|
|
||||||
self._copy_to_response(fh, length - sent)
|
|
||||||
else:
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', content_type)
|
|
||||||
self.send_header('Content-Length', str(size))
|
|
||||||
self.send_header('Accept-Ranges', 'bytes')
|
|
||||||
if is_active:
|
|
||||||
self.send_header('Cache-Control', 'no-store')
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
with open(path, 'rb') as fh:
|
|
||||||
if prefix:
|
|
||||||
self.wfile.write(prefix)
|
|
||||||
fh.seek(len(prefix))
|
|
||||||
self._copy_to_response(fh, size - len(prefix))
|
|
||||||
else:
|
|
||||||
# Bound the copy: the file may grow while we serve it, and
|
|
||||||
# writing more than Content-Length desyncs keep-alive.
|
|
||||||
self._copy_to_response(fh, size)
|
|
||||||
|
|
||||||
def _api_storage(self):
|
def _api_storage(self):
|
||||||
# 'used' is computed client-side from the file list; walking the whole
|
# 'used' is computed client-side from the file list; walking the whole
|
||||||
@@ -856,6 +494,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
with _DURATION_CACHE_LOCK:
|
||||||
|
_DURATION_CACHE.pop(filename, None)
|
||||||
|
|
||||||
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
||||||
|
|
||||||
def _api_cut(self, qs):
|
def _api_cut(self, qs):
|
||||||
@@ -962,6 +603,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
|
self._clip_started = False
|
||||||
try:
|
try:
|
||||||
if ext == '.wav':
|
if ext == '.wav':
|
||||||
self._clip_wav(path, start, end)
|
self._clip_wav(path, start, end)
|
||||||
@@ -975,10 +617,19 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
except (ConnectionError, BrokenPipeError):
|
except (ConnectionError, BrokenPipeError):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Once the audio headers are out, a JSON error body would be a
|
||||||
|
# second response written into a stream that already promised
|
||||||
|
# audio/wav and a Content-Length — with keep-alive that desyncs
|
||||||
|
# the connection for every later request on it. Drop it instead.
|
||||||
|
if self._clip_started:
|
||||||
|
print(f'Clip failed mid-stream: {e}', flush=True)
|
||||||
|
self.close_connection = True
|
||||||
|
else:
|
||||||
self._json_err(500, f'Clip failed: {e}')
|
self._json_err(500, f'Clip failed: {e}')
|
||||||
|
|
||||||
def _send_clip_headers(self, header: bytes, n_frames: int, channels: int,
|
def _send_clip_headers(self, header: bytes, n_frames: int, channels: int,
|
||||||
sampwidth: int):
|
sampwidth: int):
|
||||||
|
self._clip_started = True
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header('Content-Type', 'audio/wav')
|
self.send_header('Content-Type', 'audio/wav')
|
||||||
self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth))
|
self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth))
|
||||||
@@ -1080,6 +731,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header('Content-Type', content_type)
|
self.send_header('Content-Type', content_type)
|
||||||
self.send_header('Content-Length', str(len(body)))
|
self.send_header('Content-Length', str(len(body)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
if self.command != 'HEAD':
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _json_err(self, code: int, msg: str):
|
def _json_err(self, code: int, msg: str):
|
||||||
@@ -1094,7 +746,6 @@ class _Server(ThreadingHTTPServer):
|
|||||||
(browsers abort audio range requests constantly while seeking)."""
|
(browsers abort audio range requests constantly while seeking)."""
|
||||||
|
|
||||||
def handle_error(self, request, client_address):
|
def handle_error(self, request, client_address):
|
||||||
import sys
|
|
||||||
exc = sys.exc_info()[1]
|
exc = sys.exc_info()[1]
|
||||||
if isinstance(exc, (ConnectionError, TimeoutError)):
|
if isinstance(exc, (ConnectionError, TimeoutError)):
|
||||||
return
|
return
|
||||||
@@ -1102,10 +753,15 @@ class _Server(ThreadingHTTPServer):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# UI page — single-page HTML/CSS/JS, loaded once at startup
|
# Browser UI — static files in webui/ (index.html, style.css, js/*.js modules)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_HTML = (Path(__file__).resolve().parent / 'webui.html').read_text(encoding='utf-8')
|
WEBUI_DIR = Path(__file__).resolve().parent / 'webui'
|
||||||
|
_UI_TYPES = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1137,18 +793,21 @@ def main():
|
|||||||
|
|
||||||
if not rec_dir.exists():
|
if not rec_dir.exists():
|
||||||
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
|
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
|
||||||
|
if not (WEBUI_DIR / 'index.html').is_file():
|
||||||
|
sys.exit(f"Error: browser UI not found at {WEBUI_DIR} — keep webui/ next to web.py.")
|
||||||
|
|
||||||
prune_orphan_analyses(analyses_dir, rec_dir)
|
prune_orphan_analyses(analyses_dir, rec_dir)
|
||||||
|
|
||||||
_analyses_dir = analyses_dir # class body can't close over a name it also assigns
|
# Config lives on the handler class (one server per process). Setting the
|
||||||
class Handler(_Handler):
|
# attributes directly beats generating a subclass here, which also needed
|
||||||
recordings_dir = str(rec_dir)
|
# an alias to dodge Python's class-body scoping.
|
||||||
analyses_dir = str(_analyses_dir)
|
_Handler.recordings_dir = str(rec_dir)
|
||||||
margin_db = args.margin
|
_Handler.analyses_dir = str(analyses_dir)
|
||||||
min_gap = args.min_gap
|
_Handler.margin_db = args.margin
|
||||||
min_duration = args.min_duration
|
_Handler.min_gap = args.min_gap
|
||||||
|
_Handler.min_duration = args.min_duration
|
||||||
|
|
||||||
server = _Server((args.host, args.port), Handler)
|
server = _Server((args.host, args.port), _Handler)
|
||||||
|
|
||||||
print(f"ISR Web running on http://{args.host}:{args.port}/")
|
print(f"ISR Web running on http://{args.host}:{args.port}/")
|
||||||
print(f"Recordings dir: {rec_dir}")
|
print(f"Recordings dir: {rec_dir}")
|
||||||
|
|||||||
-1218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>ISR Archive</title>
|
||||||
|
<link rel="stylesheet" href="/ui/style.css">
|
||||||
|
<script type="module" src="/ui/js/main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||||||
|
<a href="#days" class="skip">Skip to recordings</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1 id="page-title" tabindex="-1">ISR Archive</h1>
|
||||||
|
<p id="subtitle">Loading…</p>
|
||||||
|
<p id="storage-info"></p>
|
||||||
|
<button id="refresh-btn">Refresh</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<details id="settings">
|
||||||
|
<summary>Settings</summary>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label for="margin-input">Loudness margin</label>
|
||||||
|
<input type="number" id="margin-input" min="1" max="60" step="1" aria-describedby="margin-hint">
|
||||||
|
<span id="margin-hint" class="hint">dB above the background noise for a section to count as loud</span>
|
||||||
|
|
||||||
|
<label for="min-gap-input">Grace period</label>
|
||||||
|
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" aria-describedby="min-gap-hint">
|
||||||
|
<span id="min-gap-hint" class="hint">seconds; loud sections closer than this are merged</span>
|
||||||
|
|
||||||
|
<label for="min-duration-input">Minimum length</label>
|
||||||
|
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" aria-describedby="min-duration-hint">
|
||||||
|
<span id="min-duration-hint" class="hint">seconds; shorter loud sections are ignored</span>
|
||||||
|
|
||||||
|
<label for="preroll-input">Pre-roll</label>
|
||||||
|
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" aria-describedby="preroll-hint">
|
||||||
|
<span id="preroll-hint" class="hint">seconds played before each section starts</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||||
|
<label for="filter-name">Search</label>
|
||||||
|
<input type="search" id="filter-name" placeholder="filename…" autocomplete="off">
|
||||||
|
<label for="filter-from">From</label>
|
||||||
|
<input type="date" id="filter-from">
|
||||||
|
<label for="filter-to">To</label>
|
||||||
|
<input type="date" id="filter-to">
|
||||||
|
<button id="filter-clear">Clear filters</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="days" tabindex="-1"></div>
|
||||||
|
<p id="empty" class="empty" hidden>No recordings found.</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section id="clip-bar" hidden aria-labelledby="clip-heading">
|
||||||
|
<h2 id="clip-heading" class="sr">Clip player</h2>
|
||||||
|
<div class="clip-row">
|
||||||
|
<button id="clip-prev" aria-label="Previous section (J)" aria-keyshortcuts="J">Prev</button>
|
||||||
|
<button id="clip-next" aria-label="Next section (K)" aria-keyshortcuts="K">Next</button>
|
||||||
|
<p id="clip-label"></p>
|
||||||
|
<button id="clip-context" aria-label="Open in file (O)" aria-keyshortcuts="O">Open in file</button>
|
||||||
|
<button id="clip-close" aria-label="Close clip player">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="clip-row">
|
||||||
|
<audio id="clip-audio" controls preload="auto" aria-label="Section clip"></audio>
|
||||||
|
<fieldset id="clip-adv">
|
||||||
|
<legend>When a clip ends</legend>
|
||||||
|
<label><input type="radio" name="clip-adv" value="off"> Stop</label>
|
||||||
|
<label><input type="radio" name="clip-adv" value="all" checked> Next in time</label>
|
||||||
|
<label><input type="radio" name="clip-adv" value="hl"> Next loudest</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Per-file loudness analysis: fetching (with a session cache) and the result
|
||||||
|
// cell — a meta line, the section chips, a Re-analyse button.
|
||||||
|
|
||||||
|
import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
|
||||||
|
import * as api from './api.js';
|
||||||
|
import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js';
|
||||||
|
import {analysisParams} from './settings.js';
|
||||||
|
import {playFrom} from './clips.js';
|
||||||
|
|
||||||
|
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
|
||||||
|
// refresh) never refetch what this session already has
|
||||||
|
const analysisCache = new Map();
|
||||||
|
|
||||||
|
export async function fetchAnalysis(filename, params, force = false) {
|
||||||
|
const {margin, minGap, minDur} = params;
|
||||||
|
const key = `${filename}|${margin}|${minGap}|${minDur}`;
|
||||||
|
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||||
|
const d = await api.analyze(filename, params);
|
||||||
|
if (!d.error) {
|
||||||
|
analysisCache.set(key, d);
|
||||||
|
// Keep the listing's cache info current so re-renders (filtering) still
|
||||||
|
// know the file is analysed without refetching /api/files
|
||||||
|
const f = fileByName(filename);
|
||||||
|
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetAnalyses(filename) {
|
||||||
|
for (const k of [...analysisCache.keys()])
|
||||||
|
if (k.startsWith(filename + '|')) analysisCache.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playFileSection(idx, si) {
|
||||||
|
const f = fileByIdx(idx);
|
||||||
|
if (!f) return;
|
||||||
|
const epoch = fileStartEpoch(f.date);
|
||||||
|
const queue = (sectionMap.get(idx) || []).map(s => ({
|
||||||
|
filename: f.name, start: s.start, end: s.end, score: s.score,
|
||||||
|
absStart: epoch != null ? epoch + s.start : null,
|
||||||
|
}));
|
||||||
|
playFrom(queue, si);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyseButton(idx, cell, text) {
|
||||||
|
const f = fileByIdx(idx);
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = text;
|
||||||
|
b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`);
|
||||||
|
b.addEventListener('click', () =>
|
||||||
|
analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true}));
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const para = (cls, text) => {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = cls;
|
||||||
|
p.textContent = text;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
// btn is the button that started it (optional). It stays in place and
|
||||||
|
// focusable while busy — disabling or replacing a focused button drops the
|
||||||
|
// screen reader at the top of the page. speak: the user asked for this one, so
|
||||||
|
// say how it went; background autoloads stay quiet.
|
||||||
|
export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) {
|
||||||
|
const f = fileByIdx(idx);
|
||||||
|
if (!f || cell.dataset.busy) return;
|
||||||
|
cell.dataset.busy = '1';
|
||||||
|
const span = fileSpan(f);
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = 'Analysing…';
|
||||||
|
btn.setAttribute('aria-label', `Analysing ${span}`);
|
||||||
|
btn.setAttribute('aria-disabled', 'true');
|
||||||
|
} else {
|
||||||
|
cell.replaceChildren(para('quiet', 'Analysing…'));
|
||||||
|
}
|
||||||
|
const params = analysisParams();
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = await fetchAnalysis(f.name, params, force);
|
||||||
|
} catch (e) {
|
||||||
|
d = {error: e.message};
|
||||||
|
}
|
||||||
|
delete cell.dataset.busy;
|
||||||
|
// Focus inside the cell is about to be destroyed: move it to the new content
|
||||||
|
const hadFocus = cell.contains(document.activeElement);
|
||||||
|
|
||||||
|
if (d.error) {
|
||||||
|
const b = analyseButton(idx, cell, 'Analyse');
|
||||||
|
cell.replaceChildren(para('quiet', `Error: ${d.error}`), b);
|
||||||
|
if (hadFocus) b.focus();
|
||||||
|
if (speak) announce(`Analysis of ${span} failed: ${d.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secs = d.sections || [];
|
||||||
|
const n = secs.length;
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'wbox';
|
||||||
|
box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}`
|
||||||
|
+ ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s`
|
||||||
|
+ (d.cached ? ' · cached' : '')));
|
||||||
|
|
||||||
|
if (n) {
|
||||||
|
sectionMap.set(idx, secs);
|
||||||
|
const epoch = fileStartEpoch(f.date);
|
||||||
|
const chips = document.createElement('div');
|
||||||
|
chips.className = 'chips';
|
||||||
|
chips.setAttribute('role', 'group');
|
||||||
|
chips.setAttribute('aria-label', 'Loud sections');
|
||||||
|
secs.forEach((s, si) => {
|
||||||
|
const c = document.createElement('button');
|
||||||
|
c.className = 'chip';
|
||||||
|
// Wall-clock time like the day highlights and the clip label; the
|
||||||
|
// in-file offset only matters for cutting and lives in the tooltip
|
||||||
|
c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`)
|
||||||
|
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||||||
|
c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`;
|
||||||
|
c.addEventListener('click', () => playFileSection(idx, si));
|
||||||
|
chips.appendChild(c);
|
||||||
|
});
|
||||||
|
const tog = chipToggle(chips, `${n} sections`);
|
||||||
|
if (tog) box.append(tog);
|
||||||
|
box.append(chips);
|
||||||
|
} else {
|
||||||
|
sectionMap.delete(idx);
|
||||||
|
}
|
||||||
|
box.append(analyseButton(idx, cell, 'Re-analyse'));
|
||||||
|
box.lastChild.classList.add('reanalyse-btn');
|
||||||
|
|
||||||
|
cell.replaceChildren(box);
|
||||||
|
// First control in reading order: the first chip, the chip toggle when the
|
||||||
|
// list is collapsed, or Re-analyse when there are no sections
|
||||||
|
if (hadFocus) box.querySelector('button').focus();
|
||||||
|
if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the deferred analyses of a freshly expanded day
|
||||||
|
export function autoloadDayAnalyses(dayEl) {
|
||||||
|
dayEl.querySelectorAll('td[data-autoload]').forEach(cell => {
|
||||||
|
cell.removeAttribute('data-autoload');
|
||||||
|
analyse(Number(cell.dataset.idx), cell, cell.querySelector('button'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Every URL the UI talks to, in one place. A leaf module.
|
||||||
|
|
||||||
|
const q = encodeURIComponent;
|
||||||
|
|
||||||
|
async function getJson(url) {
|
||||||
|
return (await fetch(url)).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getConfig = () => getJson('/api/config');
|
||||||
|
export const getFiles = () => getJson('/api/files');
|
||||||
|
export const getStatus = () => getJson('/api/status');
|
||||||
|
export const getStorage = () => getJson('/api/storage');
|
||||||
|
|
||||||
|
export const analyze = (file, {margin, minGap, minDur}) =>
|
||||||
|
getJson(`/api/analyze?file=${q(file)}&margin=${q(margin)}`
|
||||||
|
+ `&min_gap=${q(minGap)}&min_duration=${q(minDur)}`);
|
||||||
|
|
||||||
|
export async function deleteFile(file) {
|
||||||
|
const r = await fetch('/api/files/' + q(file), {method: 'DELETE'});
|
||||||
|
if (!r.ok) {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(d.error || r.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clipUrl = (file, start, end) =>
|
||||||
|
`/api/clip?file=${q(file)}&start=${start.toFixed(1)}&end=${end.toFixed(1)}`;
|
||||||
|
export const cutUrl = (file, start, end) => `/api/cut?file=${q(file)}&start=${start}&end=${end}`;
|
||||||
|
export const streamUrl = file => '/stream/' + q(file);
|
||||||
|
export const downloadUrl = file => '/download/' + q(file);
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// The clip player: sections play as small server-rendered WAV clips
|
||||||
|
// (/api/clip) in the bottom bar instead of seeking the full recording, which
|
||||||
|
// is slow for big FLACs. The queue holds the active review list (one file's
|
||||||
|
// sections, or a whole day's); J/K and Prev/Next step through it in time
|
||||||
|
// order, U/I step through the loudest-first ranking. The auto-advance radio
|
||||||
|
// picks what plays when a clip ends: nothing, the next in time, or the next
|
||||||
|
// by loudness.
|
||||||
|
//
|
||||||
|
// Two cursors, deliberately independent. clipCursor is the time-order position
|
||||||
|
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
|
||||||
|
// walk's own position in scoreOrder(): only U/I (and "next loudest" auto-
|
||||||
|
// advance) move it; J/K detours to review nearby clips leave it alone, so
|
||||||
|
// coming back to U/I resumes the ranking exactly where you left it. An explicit
|
||||||
|
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
|
||||||
|
|
||||||
|
import {$, fmtDur, fmtClock, announce} from './util.js';
|
||||||
|
import {fileIdxByName} from './store.js';
|
||||||
|
import {advanceMode, getPreroll} from './settings.js';
|
||||||
|
import {clipUrl} from './api.js';
|
||||||
|
import {setCutFields, seekToSection} from './player.js';
|
||||||
|
|
||||||
|
let clipQueue = [];
|
||||||
|
let clipCursor = -1;
|
||||||
|
let scoreCursor = -1;
|
||||||
|
// Where focus was when the bar opened, so closing it doesn't drop the user at
|
||||||
|
// the top of the page
|
||||||
|
let returnFocus = null;
|
||||||
|
|
||||||
|
export const hasQueue = () => clipQueue.length > 0;
|
||||||
|
|
||||||
|
// byScore: this play is part of a loudness walk (U/I or "next loudest"
|
||||||
|
// auto-advance), so the position shown is scoreCursor — the rank in the
|
||||||
|
// loudest-first ranking, labelled "by loudness" — not the time-order index.
|
||||||
|
export function playClip(i, byScore) {
|
||||||
|
if (i < 0 || i >= clipQueue.length) return;
|
||||||
|
clipCursor = i;
|
||||||
|
const c = clipQueue[i];
|
||||||
|
const cs = Math.max(0, c.start - getPreroll());
|
||||||
|
const ce = c.end + 1.5; // small tail after the section
|
||||||
|
const a = $('clip-audio');
|
||||||
|
a.src = clipUrl(c.filename, cs, ce);
|
||||||
|
a.play().catch(() => {});
|
||||||
|
// Label = wall-clock time of occurrence (absStart from the filename clock);
|
||||||
|
// falls back to in-file offsets for non-standard filenames. The visible
|
||||||
|
// label, the screen-reader announcement and the lock-screen title use one
|
||||||
|
// identical string: "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" —
|
||||||
|
// the count is the time-order index "by time" (J/K) or the loudness rank
|
||||||
|
// "by loudness" (U/I).
|
||||||
|
const when = c.absStart != null
|
||||||
|
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
|
||||||
|
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
|
||||||
|
const score = c.score != null ? ` · +${Math.round(c.score)} dB` : '';
|
||||||
|
const pos = byScore ? scoreCursor : i;
|
||||||
|
const dim = byScore ? 'by loudness' : 'by time';
|
||||||
|
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
|
||||||
|
const label = $('clip-label');
|
||||||
|
label.textContent = text;
|
||||||
|
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
|
||||||
|
|
||||||
|
const bar = $('clip-bar');
|
||||||
|
const ae = document.activeElement;
|
||||||
|
if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae;
|
||||||
|
bar.hidden = false;
|
||||||
|
document.body.classList.add('clip-open');
|
||||||
|
const rowIdx = fileIdxByName(c.filename);
|
||||||
|
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
|
||||||
|
if ('mediaSession' in navigator && 'MediaMetadata' in window)
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename});
|
||||||
|
announce(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideClipBar() {
|
||||||
|
const a = $('clip-audio');
|
||||||
|
a.pause();
|
||||||
|
a.removeAttribute('src');
|
||||||
|
$('clip-bar').hidden = true;
|
||||||
|
document.body.classList.remove('clip-open');
|
||||||
|
if ('mediaSession' in navigator) navigator.mediaSession.metadata = null;
|
||||||
|
clipQueue = [];
|
||||||
|
clipCursor = -1;
|
||||||
|
scoreCursor = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point the queue at a new section list without playing: nothing is selected
|
||||||
|
// yet, and the next U/I must re-anchor here rather than resume a walk through
|
||||||
|
// the previous queue.
|
||||||
|
export function armQueue(sections) {
|
||||||
|
clipQueue = sections;
|
||||||
|
clipCursor = -1;
|
||||||
|
scoreCursor = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit jump (chip click): play section i of sections; the next U/I
|
||||||
|
// re-anchors on it
|
||||||
|
export function playFrom(sections, i) {
|
||||||
|
clipQueue = sections;
|
||||||
|
scoreCursor = -1;
|
||||||
|
playClip(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight order = section indices sorted loudest-first. U/I (and "next
|
||||||
|
// loudest" auto-advance) walk this ranking, so a review runs from the most to
|
||||||
|
// the least interesting event until the user stops — no top-N cutoff.
|
||||||
|
// Cached against the section array's identity: a day queue runs to thousands
|
||||||
|
// of entries and U/I would otherwise re-sort all of them on every keypress.
|
||||||
|
// Every arming path assigns a fresh array, so identity is a safe key.
|
||||||
|
let scoreOrderCache = {secs: null, order: null};
|
||||||
|
export function scoreOrder(secs) {
|
||||||
|
if (scoreOrderCache.secs === secs) return scoreOrderCache.order;
|
||||||
|
const order = secs.map((s, i) => ({i, score: s.score || 0}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(r => r.i);
|
||||||
|
scoreOrderCache = {secs, order};
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step a position within an ordering: pos + dir, or straight to the extreme in
|
||||||
|
// that direction when jump. Returns null when the step runs off either end.
|
||||||
|
// pos === -1 means "not anchored yet" — start at the first entry going forward,
|
||||||
|
// or off the front going backward so the caller announces the boundary.
|
||||||
|
// Shared by both U/I walks (clip queue and in-player); they differ only in what
|
||||||
|
// the anchor is and what "play" means, and drifting apart is exactly how the
|
||||||
|
// two would stop agreeing.
|
||||||
|
export function nextInOrder(order, pos, dir, jump) {
|
||||||
|
const next = jump ? (dir > 0 ? order.length - 1 : 0)
|
||||||
|
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
|
||||||
|
return (next >= 0 && next < order.length) ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step the clip queue by dir (±1): in time order, or with byScore down/up
|
||||||
|
// the loudest-first ranking (next = next quieter). With jump=true, go straight
|
||||||
|
// to the extreme in that direction (first/last section, or loudest/quietest).
|
||||||
|
export function stepClip(dir, byScore, jump) {
|
||||||
|
if (!clipQueue.length) return;
|
||||||
|
if (byScore) {
|
||||||
|
// Walk scoreCursor, not the shared clipCursor: a J/K detour never moved
|
||||||
|
// it, so we resume the ranking where we left off. scoreCursor === -1 means
|
||||||
|
// the walk hasn't started since the queue was armed or an explicit jump —
|
||||||
|
// anchor on the section currently selected (clipCursor), else the loudest.
|
||||||
|
const order = scoreOrder(clipQueue);
|
||||||
|
const pos = scoreCursor !== -1 ? scoreCursor
|
||||||
|
: clipCursor >= 0 ? order.indexOf(clipCursor)
|
||||||
|
: -1;
|
||||||
|
const next = nextInOrder(order, pos, dir, jump);
|
||||||
|
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||||
|
scoreCursor = next;
|
||||||
|
playClip(order[next], true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// J/K (time order) deliberately does NOT touch scoreCursor.
|
||||||
|
const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir;
|
||||||
|
if (i >= 0 && i < clipQueue.length) playClip(i);
|
||||||
|
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Open in file": switch from the current clip to the full recording at the
|
||||||
|
// same position. Bound to the clip-bar button and the O key.
|
||||||
|
export function openClipInFile() {
|
||||||
|
const c = clipQueue[clipCursor];
|
||||||
|
if (!c) return;
|
||||||
|
const idx = fileIdxByName(c.filename);
|
||||||
|
if (idx == null) return;
|
||||||
|
$('clip-audio').pause();
|
||||||
|
const row = $('row-' + idx);
|
||||||
|
if (row) {
|
||||||
|
const wrap = row.closest('.table-wrap');
|
||||||
|
if (wrap?.hidden) // file's day is collapsed: expand it
|
||||||
|
$('daytgl-' + wrap.id.slice('daytbl-'.length))?.click();
|
||||||
|
row.scrollIntoView({block: 'center'});
|
||||||
|
}
|
||||||
|
seekToSection(idx, c.start, c.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeClipBar() {
|
||||||
|
const back = returnFocus;
|
||||||
|
returnFocus = null;
|
||||||
|
hideClipBar();
|
||||||
|
(back?.isConnected ? back : $('page-title')).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initClipBar() {
|
||||||
|
$('clip-prev').addEventListener('click', () => stepClip(-1, false));
|
||||||
|
$('clip-next').addEventListener('click', () => stepClip(1, false));
|
||||||
|
$('clip-close').addEventListener('click', closeClipBar);
|
||||||
|
$('clip-context').addEventListener('click', openClipInFile);
|
||||||
|
$('clip-audio').addEventListener('ended', () => {
|
||||||
|
const mode = advanceMode();
|
||||||
|
if (mode !== 'off') stepClip(1, mode === 'hl');
|
||||||
|
});
|
||||||
|
$('clip-bar').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
// Day review: the chronological cross-file section list behind a day's
|
||||||
|
// Highlights button.
|
||||||
|
//
|
||||||
|
// The user is blind and uses a screen reader — there is deliberately no day
|
||||||
|
// timeline graphic. The panel is linear text and buttons: summary line ->
|
||||||
|
// key-hint note -> chips toggle -> chips.
|
||||||
|
|
||||||
|
import {$, fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
|
||||||
|
import {fetchAnalysis} from './analysis.js';
|
||||||
|
import {armQueue, playFrom, hideClipBar} from './clips.js';
|
||||||
|
import {analysisParams, hlParams} from './settings.js';
|
||||||
|
|
||||||
|
let dayActiveSections = [];
|
||||||
|
let dayActiveId = null;
|
||||||
|
// dayId -> section list of an already-built highlights panel, so collapsing
|
||||||
|
// and re-expanding it re-arms J/K without recomputing
|
||||||
|
const dayHlSections = new Map();
|
||||||
|
|
||||||
|
export function setHlExpanded(dayId, exp) {
|
||||||
|
const btn = $('dayhln-' + dayId);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.setAttribute('aria-expanded', exp);
|
||||||
|
const arrow = btn.querySelector('.day-arrow');
|
||||||
|
if (arrow) arrow.textContent = exp ? '▾ ' : '▸ ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status suffix inside the Highlights button (" · analysed", progress).
|
||||||
|
// The button has no aria-label, so this text is part of its accessible name.
|
||||||
|
const setHlStatus = (dayId, text) => {
|
||||||
|
const el = $('dayhls-' + dayId);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Point the clip queue at a day's cross-file section list, from either the
|
||||||
|
// fresh-build path or the re-open-a-kept-panel path. Coupled globals set in
|
||||||
|
// two places is how the stale-_idx and diverging-label bugs happened.
|
||||||
|
function armDayQueue(dayId, sections) {
|
||||||
|
dayActiveSections = sections;
|
||||||
|
dayActiveId = dayId;
|
||||||
|
armQueue(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToDaySection(si) {
|
||||||
|
if (si < 0 || si >= dayActiveSections.length) return;
|
||||||
|
playFrom(dayActiveSections, si);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day was collapsed: close its panel and drop its queue
|
||||||
|
export function releaseDay(dayId) {
|
||||||
|
const panel = $('dayhl-' + dayId);
|
||||||
|
if (panel) panel.hidden = true;
|
||||||
|
setHlExpanded(dayId, false);
|
||||||
|
if (dayActiveId === dayId) {
|
||||||
|
dayActiveSections = [];
|
||||||
|
dayActiveId = null;
|
||||||
|
hideClipBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlights button: expand/collapse the panel; only (re)compute when it has
|
||||||
|
// not been built yet this session or the analysis params changed
|
||||||
|
export function toggleHighlights(dayId, files) {
|
||||||
|
const panel = $('dayhl-' + dayId);
|
||||||
|
const btn = $('dayhln-' + dayId);
|
||||||
|
if (btn?.dataset.busy) return;
|
||||||
|
if (!panel.hidden) { // collapse, keep the panel
|
||||||
|
panel.hidden = true;
|
||||||
|
setHlExpanded(dayId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (panel.dataset.loaded === hlParams()) { // re-open and re-arm J/K
|
||||||
|
panel.hidden = false;
|
||||||
|
setHlExpanded(dayId, true);
|
||||||
|
armDayQueue(dayId, dayHlSections.get(dayId) || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dayHighlights(dayId, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dayHighlights(dayId, files) {
|
||||||
|
const panel = $('dayhl-' + dayId);
|
||||||
|
const content = $('dayhlc-' + dayId);
|
||||||
|
const btn = $('dayhln-' + dayId);
|
||||||
|
const n = files.length;
|
||||||
|
|
||||||
|
panel.hidden = false;
|
||||||
|
setHlExpanded(dayId, true);
|
||||||
|
// Busy, not disabled: disabling the focused button would throw the screen
|
||||||
|
// reader's focus to the top of the page
|
||||||
|
btn.dataset.busy = '1';
|
||||||
|
btn.setAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
|
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
|
||||||
|
const progFill = document.createElement('div'); progFill.className = 'prog-fill';
|
||||||
|
progBar.setAttribute('aria-hidden', 'true');
|
||||||
|
progBar.appendChild(progFill);
|
||||||
|
const progFile = document.createElement('p'); progFile.className = 'prog-file';
|
||||||
|
content.replaceChildren(progBar, progFile);
|
||||||
|
|
||||||
|
const params = analysisParams();
|
||||||
|
const results = [];
|
||||||
|
let nCached = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const f = files[i];
|
||||||
|
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||||||
|
progFill.style.width = `${(i / n) * 100}%`;
|
||||||
|
setHlStatus(dayId, ` · ${i + 1} of ${n}`);
|
||||||
|
try {
|
||||||
|
const d = await fetchAnalysis(f.name, params);
|
||||||
|
if (!d.error) { results.push({f, data: d}); if (d.cached) nCached++; }
|
||||||
|
} catch { /* counted as missing below */ }
|
||||||
|
}
|
||||||
|
delete btn.dataset.busy;
|
||||||
|
btn.removeAttribute('aria-disabled');
|
||||||
|
// Every file is now cached with the current params
|
||||||
|
setHlStatus(dayId, results.length === n ? ' · analysed' : '');
|
||||||
|
|
||||||
|
// Map files onto the day. The filename is the clock: f.date is the
|
||||||
|
// recording start parsed server-side; mtime (≈ file end) is only a fallback
|
||||||
|
// for non-standard names.
|
||||||
|
const sections = [];
|
||||||
|
results.forEach(({f, data}) => {
|
||||||
|
const fileDur = data.duration || f.duration || 0;
|
||||||
|
if (fileDur <= 0) return;
|
||||||
|
const startEpoch = fileStartEpoch(f.date);
|
||||||
|
const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
|
||||||
|
(data.sections || []).forEach(s => sections.push({
|
||||||
|
filename: f.name, start: s.start, end: s.end, score: s.score,
|
||||||
|
absStart: fileStart + s.start,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
sections.sort((a, b) => a.absStart - b.absStart);
|
||||||
|
// Arm the clip queue so J/K steps through the day immediately
|
||||||
|
armDayQueue(dayId, sections);
|
||||||
|
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'wbox';
|
||||||
|
const nRes = results.length, nSec = sections.length;
|
||||||
|
const missing = n - nRes;
|
||||||
|
// Totals first, then the key hint, then the chips: linear reading order
|
||||||
|
const summary = document.createElement('p');
|
||||||
|
summary.className = 'quiet';
|
||||||
|
summary.textContent = `${nRes} recording${nRes !== 1 ? 's' : ''} analysed`
|
||||||
|
+ (nCached ? ` (${nCached} from cache)` : '')
|
||||||
|
+ (missing ? `, ${missing} failed` : '')
|
||||||
|
+ ` · ${nSec} loud section${nSec !== 1 ? 's' : ''}`;
|
||||||
|
box.appendChild(summary);
|
||||||
|
|
||||||
|
if (nSec) {
|
||||||
|
const note = document.createElement('p');
|
||||||
|
note.className = 'quiet';
|
||||||
|
note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)';
|
||||||
|
box.appendChild(note);
|
||||||
|
|
||||||
|
const MAX_DAY_CHIPS = 50;
|
||||||
|
// When there are too many sections to show them all, show the ones most
|
||||||
|
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
|
||||||
|
let chipList = sections.map((sec, si) => ({sec, si}));
|
||||||
|
const truncated = chipList.length > MAX_DAY_CHIPS;
|
||||||
|
if (truncated) {
|
||||||
|
chipList = chipList
|
||||||
|
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
|
||||||
|
.slice(0, MAX_DAY_CHIPS);
|
||||||
|
}
|
||||||
|
const chips = document.createElement('div');
|
||||||
|
chips.className = 'chips';
|
||||||
|
chips.setAttribute('role', 'group');
|
||||||
|
chips.setAttribute('aria-label', 'Day loud sections');
|
||||||
|
chipList.forEach(({sec, si}) => {
|
||||||
|
const c = document.createElement('button');
|
||||||
|
c.className = 'chip';
|
||||||
|
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
||||||
|
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
|
||||||
|
c.addEventListener('click', () => jumpToDaySection(si));
|
||||||
|
chips.appendChild(c);
|
||||||
|
});
|
||||||
|
const tog = chipToggle(chips, truncated
|
||||||
|
? `Top ${chipList.length} sections by loudness`
|
||||||
|
: `${chipList.length} sections`);
|
||||||
|
if (tog) box.appendChild(tog);
|
||||||
|
box.appendChild(chips);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.replaceChildren(box);
|
||||||
|
panel.dataset.loaded = hlParams();
|
||||||
|
dayHlSections.set(dayId, sections);
|
||||||
|
announce(nSec
|
||||||
|
? `${nSec} loud section${nSec !== 1 ? 's' : ''} across ${nRes} recording${nRes !== 1 ? 's' : ''}. K plays the first, I the loudest.`
|
||||||
|
: `No loud sections in ${nRes} recording${nRes !== 1 ? 's' : ''}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// The recordings listing: one collapsible section per day, a table of
|
||||||
|
// recordings inside, plus loading, filtering, deleting and status polling.
|
||||||
|
|
||||||
|
import {$, esc, fmtDur, fmtDurWords, fmtSize, pad, parseTime, dayLabel, announce} from './util.js';
|
||||||
|
import * as api from './api.js';
|
||||||
|
import {allFiles, setFiles, removeFile, sectionMap, fileSpan, isAnalysable} from './store.js';
|
||||||
|
import {cachedParamsMatch, hlParams} from './settings.js';
|
||||||
|
import {analyse, analyseButton, autoloadDayAnalyses, forgetAnalyses} from './analysis.js';
|
||||||
|
import {togglePlayer, closePlayer, forgetPlayer, toggleCut} from './player.js';
|
||||||
|
import {toggleHighlights, releaseDay} from './day.js';
|
||||||
|
|
||||||
|
// dayId -> boolean, persists expanded state across re-renders
|
||||||
|
const dayExpanded = new Map();
|
||||||
|
|
||||||
|
function groupByDay(files) {
|
||||||
|
const map = new Map();
|
||||||
|
files.forEach(f => {
|
||||||
|
const day = f.date.slice(0, 10);
|
||||||
|
if (!map.has(day)) map.set(day, []);
|
||||||
|
map.get(day).push(f);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local date, not toISOString() — f.date is the recording start in local
|
||||||
|
// time, so a UTC "today" leaves the current day collapsed for part of every
|
||||||
|
// day in any non-UTC timezone (00:00–02:00 in UTC+2, all evening in UTC−5).
|
||||||
|
const localToday = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything a day's markup is built from. A day whose signature is unchanged
|
||||||
|
// keeps its DOM across reloads — open players, analyses, the highlights panel
|
||||||
|
// and, above all, the screen reader's position in it.
|
||||||
|
const daySignature = dayFiles => hlParams() + '#'
|
||||||
|
+ dayFiles.map(f => `${f.name}:${f.recording ? 1 : 0}:${f.duration ?? ''}`).join('|');
|
||||||
|
|
||||||
|
function buildRow(f, tbody, expanded) {
|
||||||
|
const i = f._idx;
|
||||||
|
const isRec = !!f.recording;
|
||||||
|
const span = esc(fileSpan(f));
|
||||||
|
const off = isRec ? ' disabled' : '';
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'data-row';
|
||||||
|
tr.id = 'row-' + i;
|
||||||
|
tr.dataset.idx = i;
|
||||||
|
// Time is the row header, so table navigation names the row by its span.
|
||||||
|
// The player opens inside its own cell; the options cell right after it is
|
||||||
|
// always there, and Cut expands its fields inside it. Format, size and
|
||||||
|
// filename come last: rarely needed, and the filename reads as a string of
|
||||||
|
// digits.
|
||||||
|
tr.innerHTML = `
|
||||||
|
<th scope="row" class="when">${span} <span id="rec-${i}" class="badge-rec"${isRec ? '' : ' hidden'}><span aria-hidden="true">● </span>Recording</span></th>
|
||||||
|
<td class="nowrap">${fmtDur(f.duration)}</td>
|
||||||
|
<td id="wave-${i}" class="loudness">${isAnalysable(f) ? '' : '<span class="muted">Not available for this format</span>'}</td>
|
||||||
|
<td class="player">
|
||||||
|
<button id="pbtn-${i}" aria-expanded="false" aria-controls="aud-${i}" aria-label="Play ${span}">Play</button>
|
||||||
|
<audio id="aud-${i}" controls preload="none" aria-label="Recording ${span}" hidden></audio>
|
||||||
|
</td>
|
||||||
|
<td class="options">
|
||||||
|
<div class="actions">
|
||||||
|
<a class="dl" href="${api.downloadUrl(f.name)}" aria-label="Download ${span}">Download</a>
|
||||||
|
<button id="cutbtn-${i}" class="cut" aria-expanded="false" aria-controls="cut-${i}" aria-label="Cut ${span}"${off}><span
|
||||||
|
class="day-arrow" aria-hidden="true">▸ </span>Cut</button>
|
||||||
|
<button id="delbtn-${i}" class="del" aria-label="Delete ${span}"${off}>Delete</button>
|
||||||
|
</div>
|
||||||
|
<div class="cut-panel" id="cut-${i}" role="group" aria-label="Cut" hidden>
|
||||||
|
<label class="cut-field">Start
|
||||||
|
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="cut-field">End
|
||||||
|
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<button id="cut-dl-${i}" class="cut">Download cut</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="file"><span class="badge badge-${esc(f.ext)}">${esc(f.ext.toUpperCase())}</span> ${fmtSize(f.size)} · <span class="fn">${esc(f.name)}</span></td>`;
|
||||||
|
tbody.append(tr);
|
||||||
|
|
||||||
|
if (isAnalysable(f)) {
|
||||||
|
const cell = tr.querySelector('#wave-' + i);
|
||||||
|
const abtn = analyseButton(i, cell, 'Analyse');
|
||||||
|
cell.appendChild(abtn);
|
||||||
|
if (isRec) {
|
||||||
|
abtn.disabled = true;
|
||||||
|
} else if (cachedParamsMatch(f.cached_analysis)) {
|
||||||
|
if (expanded) analyse(i, cell, abtn);
|
||||||
|
// Collapsed day: defer the fetch until the day is opened
|
||||||
|
else { cell.dataset.autoload = '1'; cell.dataset.idx = i; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.querySelector('#pbtn-' + i).addEventListener('click', () => togglePlayer(i));
|
||||||
|
if (!isRec) {
|
||||||
|
tr.querySelector('#cutbtn-' + i).addEventListener('click', () => toggleCut(i));
|
||||||
|
tr.querySelector('#delbtn-' + i).addEventListener('click', () => deleteFile(f));
|
||||||
|
tr.querySelector('#cut-dl-' + i).addEventListener('click', () => {
|
||||||
|
const start = parseTime($('cut-start-' + i).value);
|
||||||
|
const end = parseTime($('cut-end-' + i).value);
|
||||||
|
if (start === null || end === null) { alert('Enter start and end times, e.g. 1:30 or 0:01:30'); return; }
|
||||||
|
if (start >= end) { alert('Start must be before end'); return; }
|
||||||
|
window.location.href = api.cutUrl(f.name, start, end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDay(day, dayFiles) {
|
||||||
|
const dayId = 'day-' + day.replace(/-/g, '');
|
||||||
|
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === localToday());
|
||||||
|
const expanded = dayExpanded.get(dayId);
|
||||||
|
const label = esc(dayLabel(day));
|
||||||
|
|
||||||
|
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
|
||||||
|
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
|
||||||
|
const hlFiles = dayFiles.filter(f => isAnalysable(f) && !f.recording);
|
||||||
|
// Every analysable file already has a cached analysis for the current
|
||||||
|
// params -> the day's highlights are available without recomputing
|
||||||
|
const analysed = hlFiles.length > 0 && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
|
||||||
|
const meta = `${dayFiles.length} recording${dayFiles.length !== 1 ? 's' : ''}`
|
||||||
|
+ (totalDur > 0 ? ' · ' + fmtDurWords(totalDur) : '') + ' · ' + fmtSize(totalSize);
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'day-section';
|
||||||
|
el.id = 'daysec-' + dayId;
|
||||||
|
el.dataset.day = day;
|
||||||
|
// The Highlights button has no aria-label on purpose: its content is its
|
||||||
|
// name, so the " · analysed" / progress suffix is heard without mirroring.
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="day-heading-bar${expanded ? ' open' : ''}">
|
||||||
|
<h2 class="day-heading">
|
||||||
|
<button class="day-toggle" id="daytgl-${dayId}" aria-expanded="${expanded}" aria-controls="daytbl-${dayId}"><span
|
||||||
|
class="day-arrow" aria-hidden="true">${expanded ? '▾ ' : '▸ '}</span>${label}<span
|
||||||
|
class="day-meta"><span class="sr">, </span>${esc(meta)}</span></button>
|
||||||
|
</h2>
|
||||||
|
${hlFiles.length ? `<button class="day-hl" id="dayhln-${dayId}" aria-expanded="false" aria-controls="dayhl-${dayId}"><span
|
||||||
|
class="day-arrow" aria-hidden="true">▸ </span>Highlights<span class="sr"> for ${label}</span><span
|
||||||
|
class="day-hl-status" id="dayhls-${dayId}">${analysed ? ' · analysed' : ''}</span></button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="day-hl-container" id="dayhl-${dayId}" hidden><div id="dayhlc-${dayId}"></div></div>
|
||||||
|
<div class="table-wrap" id="daytbl-${dayId}"${expanded ? '' : ' hidden'}>
|
||||||
|
<table class="day-table">
|
||||||
|
<caption class="sr">Recordings, ${label}</caption>
|
||||||
|
<thead><tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Length</th>
|
||||||
|
<th scope="col">Loudness</th>
|
||||||
|
<th scope="col">Player</th>
|
||||||
|
<th scope="col">Options</th>
|
||||||
|
<th scope="col">File</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const tbody = el.querySelector('tbody');
|
||||||
|
dayFiles.forEach(f => buildRow(f, tbody, expanded));
|
||||||
|
|
||||||
|
const headBar = el.querySelector('.day-heading-bar');
|
||||||
|
const wrap = el.querySelector('.table-wrap');
|
||||||
|
const tgl = el.querySelector('.day-toggle');
|
||||||
|
tgl.addEventListener('click', () => {
|
||||||
|
const nowExp = !dayExpanded.get(dayId);
|
||||||
|
dayExpanded.set(dayId, nowExp);
|
||||||
|
tgl.setAttribute('aria-expanded', nowExp);
|
||||||
|
tgl.querySelector('.day-arrow').textContent = nowExp ? '▾ ' : '▸ ';
|
||||||
|
headBar.classList.toggle('open', nowExp);
|
||||||
|
wrap.hidden = !nowExp;
|
||||||
|
if (nowExp) {
|
||||||
|
autoloadDayAnalyses(wrap);
|
||||||
|
} else {
|
||||||
|
dayFiles.forEach(f => closePlayer(f._idx));
|
||||||
|
releaseDay(dayId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.querySelector('.day-hl')?.addEventListener('click', () => toggleHighlights(dayId, hlFiles));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardDay(el) {
|
||||||
|
el.querySelectorAll('audio').forEach(a => a.pause());
|
||||||
|
el.querySelectorAll('tr.data-row').forEach(tr => {
|
||||||
|
const i = Number(tr.dataset.idx);
|
||||||
|
sectionMap.delete(i);
|
||||||
|
forgetPlayer(i);
|
||||||
|
});
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the listing, rebuilding only the days whose content changed. A
|
||||||
|
// rollover touches today alone, so the rest of the page — and whatever the
|
||||||
|
// screen reader is reading — stays put. Focus inside a rebuilt day is put back
|
||||||
|
// on the element with the same id (ids are stable per filename, see store.js).
|
||||||
|
function renderFiles(files) {
|
||||||
|
const container = $('days');
|
||||||
|
const focusId = document.activeElement?.id || null;
|
||||||
|
|
||||||
|
const total = allFiles.length, visible = files.length;
|
||||||
|
$('subtitle').textContent = total === visible
|
||||||
|
? `${total} recording${total !== 1 ? 's' : ''}`
|
||||||
|
: `${visible} of ${total} recording${total !== 1 ? 's' : ''} shown`;
|
||||||
|
$('empty').hidden = visible > 0;
|
||||||
|
|
||||||
|
const old = new Map([...container.children].map(el => [el.dataset.day, el]));
|
||||||
|
let prev = null;
|
||||||
|
groupByDay(files).forEach((dayFiles, day) => {
|
||||||
|
const sig = daySignature(dayFiles);
|
||||||
|
let el = old.get(day);
|
||||||
|
old.delete(day);
|
||||||
|
if (!el || el.dataset.sig !== sig) {
|
||||||
|
if (el) discardDay(el);
|
||||||
|
el = buildDay(day, dayFiles);
|
||||||
|
el.dataset.sig = sig;
|
||||||
|
}
|
||||||
|
// Only move nodes that are out of place: moving one blurs focus inside it
|
||||||
|
const ref = prev ? prev.nextSibling : container.firstChild;
|
||||||
|
if (el !== ref) container.insertBefore(el, ref);
|
||||||
|
prev = el;
|
||||||
|
});
|
||||||
|
old.forEach(discardDay);
|
||||||
|
|
||||||
|
if (focusId && document.activeElement?.id !== focusId) $(focusId)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFilters({speak = false} = {}) {
|
||||||
|
const nameQ = $('filter-name').value.toLowerCase().trim();
|
||||||
|
const fromD = $('filter-from').value;
|
||||||
|
const toD = $('filter-to').value;
|
||||||
|
renderFiles(allFiles.filter(f => {
|
||||||
|
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||||||
|
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||||||
|
if (toD && f.date > toD + ' 23:59:59') return false;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
if (speak) announce($('subtitle').textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStorage() {
|
||||||
|
try {
|
||||||
|
const s = await api.getStorage();
|
||||||
|
const used = allFiles.reduce((a, f) => a + f.size, 0);
|
||||||
|
let txt = fmtSize(used) + ' used';
|
||||||
|
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
|
||||||
|
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
|
||||||
|
$('storage-info').textContent = txt;
|
||||||
|
} catch { /* keep the last value */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
// speak: the user pressed Refresh, so say what came back. Poll-driven reloads
|
||||||
|
// stay silent — a rollover every hour must not interrupt whatever is being read.
|
||||||
|
export async function load({speak = false} = {}) {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
const btn = $('refresh-btn');
|
||||||
|
btn.setAttribute('aria-disabled', 'true');
|
||||||
|
try {
|
||||||
|
setFiles(await api.getFiles());
|
||||||
|
updateStorage();
|
||||||
|
applyFilters();
|
||||||
|
if (speak) announce('Refreshed. ' + $('subtitle').textContent);
|
||||||
|
} catch {
|
||||||
|
$('subtitle').textContent = 'Error loading recordings';
|
||||||
|
if (speak) announce('Could not load recordings');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
btn.removeAttribute('aria-disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(f) {
|
||||||
|
const span = fileSpan(f);
|
||||||
|
if (!confirm(`Delete the recording ${span} (${f.name})?\nThis cannot be undone.`)) return;
|
||||||
|
const btn = $('delbtn-' + f._idx);
|
||||||
|
if (btn) btn.setAttribute('aria-disabled', 'true');
|
||||||
|
// Where focus goes once the row is gone: the next recording's Play button,
|
||||||
|
// else the previous one's, else the day's heading button
|
||||||
|
const row = $('row-' + f._idx);
|
||||||
|
const rows = [...row.parentNode.querySelectorAll('tr.data-row')];
|
||||||
|
const k = rows.indexOf(row);
|
||||||
|
const near = rows[k + 1] || rows[k - 1];
|
||||||
|
const dayToggle = row.closest('.day-section').querySelector('.day-toggle');
|
||||||
|
try {
|
||||||
|
await api.deleteFile(f.name);
|
||||||
|
} catch (e) {
|
||||||
|
if (btn) btn.removeAttribute('aria-disabled');
|
||||||
|
alert('Delete failed: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeFile(f.name);
|
||||||
|
forgetAnalyses(f.name);
|
||||||
|
applyFilters();
|
||||||
|
updateStorage();
|
||||||
|
// The day was rebuilt without the row; look the targets up again by id
|
||||||
|
const target = (near && $('pbtn-' + near.dataset.idx)) || $(dayToggle.id) || $('page-title');
|
||||||
|
target.focus();
|
||||||
|
announce(`Deleted ${span}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll recording status every 5 s to update REC badges
|
||||||
|
let lastActiveKey = null;
|
||||||
|
export async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const s = await api.getStatus();
|
||||||
|
const active = new Set(s.active || []);
|
||||||
|
// Rows not currently rendered (filtered out) have no badge element, so
|
||||||
|
// getElementById skips them.
|
||||||
|
allFiles.forEach(f => {
|
||||||
|
const badge = $('rec-' + f._idx);
|
||||||
|
if (badge) badge.hidden = !active.has(f.name);
|
||||||
|
});
|
||||||
|
// Active set changed (recording started/stopped or rolled over to a new
|
||||||
|
// split file): refresh the list so new files appear — but never yank the
|
||||||
|
// DOM out from under an in-progress playback.
|
||||||
|
const key = [...active].sort().join('|');
|
||||||
|
if (lastActiveKey === null) { lastActiveKey = key; return; }
|
||||||
|
if (key !== lastActiveKey) {
|
||||||
|
const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended);
|
||||||
|
if (!playing) { lastActiveKey = key; load(); }
|
||||||
|
}
|
||||||
|
} catch { /* next poll */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Entry point: wire the page up and start.
|
||||||
|
//
|
||||||
|
// Modules import only from modules earlier in this list:
|
||||||
|
// util, api, store, settings, player, clips, analysis, day, list, nav, main
|
||||||
|
// Keep it that way. A module that needs something from a later one reaches it
|
||||||
|
// through the DOM (openClipInFile clicks the day toggle) rather than an import.
|
||||||
|
|
||||||
|
import {$} from './util.js';
|
||||||
|
import {getConfig} from './api.js';
|
||||||
|
import {initSettings} from './settings.js';
|
||||||
|
import {initClipBar} from './clips.js';
|
||||||
|
import {initNav} from './nav.js';
|
||||||
|
import {load, applyFilters, pollStatus} from './list.js';
|
||||||
|
|
||||||
|
initClipBar();
|
||||||
|
initNav();
|
||||||
|
|
||||||
|
$('refresh-btn').addEventListener('click', () => load({speak: true}));
|
||||||
|
|
||||||
|
let filterDebounce;
|
||||||
|
$('filter-name').addEventListener('input', () => {
|
||||||
|
clearTimeout(filterDebounce);
|
||||||
|
filterDebounce = setTimeout(() => applyFilters({speak: true}), 400);
|
||||||
|
});
|
||||||
|
$('filter-from').addEventListener('change', () => applyFilters({speak: true}));
|
||||||
|
$('filter-to').addEventListener('change', () => applyFilters({speak: true}));
|
||||||
|
$('filter-clear').addEventListener('click', () => {
|
||||||
|
$('filter-name').value = '';
|
||||||
|
$('filter-from').value = '';
|
||||||
|
$('filter-to').value = '';
|
||||||
|
applyFilters({speak: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
getConfig().catch(() => ({})).then(initSettings)
|
||||||
|
.finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Section navigation from the keyboard (J/K/U/I/O) and from media keys —
|
||||||
|
// headset buttons, AirPods, the lock screen — via the Media Session API.
|
||||||
|
//
|
||||||
|
// J/K = previous/next section in time order, U/I = up/down the loudest-first
|
||||||
|
// ranking, O = open the current clip in the full file. Holding Shift jumps to
|
||||||
|
// the extreme in that direction: Shift+J/K = first/last section in time,
|
||||||
|
// Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue
|
||||||
|
// armed, the keys step the playhead of the open full-file player instead.
|
||||||
|
|
||||||
|
import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js';
|
||||||
|
import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js';
|
||||||
|
import {activePlayerIdx, setCutFields} from './player.js';
|
||||||
|
import {sectionMap, fileByIdx} from './store.js';
|
||||||
|
import {getPreroll} from './settings.js';
|
||||||
|
|
||||||
|
export function navigate(dir, byScore, jump) {
|
||||||
|
if (hasQueue()) { stepClip(dir, byScore, jump); return; }
|
||||||
|
|
||||||
|
// Per-file in-player navigation (full-file listening, no clip queue)
|
||||||
|
const idx = activePlayerIdx;
|
||||||
|
if (idx === null) return;
|
||||||
|
const all = sectionMap.get(idx) || [];
|
||||||
|
const audio = document.getElementById('aud-' + idx);
|
||||||
|
if (!all.length || !audio) return;
|
||||||
|
const preroll = getPreroll();
|
||||||
|
const cur = audio.currentTime;
|
||||||
|
const epoch = fileStartEpoch(fileByIdx(idx)?.date);
|
||||||
|
|
||||||
|
const jumpTo = (s, i, n, word) => {
|
||||||
|
audio.currentTime = Math.max(0, s.start - preroll);
|
||||||
|
setCutFields(idx, s.start, s.end);
|
||||||
|
const when = epoch != null
|
||||||
|
? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}`
|
||||||
|
: `${fmtDur(s.start)} to ${fmtDur(s.end)}`;
|
||||||
|
announce(`${word} ${i + 1} of ${n}: ${when}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (byScore) {
|
||||||
|
// U/I walk the ranking; the anchor is the section the playhead sits in
|
||||||
|
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
|
||||||
|
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
|
||||||
|
const order = scoreOrder(all);
|
||||||
|
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
|
||||||
|
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
|
||||||
|
const next = nextInOrder(order, pos, dir, jump);
|
||||||
|
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||||
|
jumpTo(all[order[next]], next, order.length, 'Highlight');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift+J/K jump to the first/last section regardless of the playhead.
|
||||||
|
if (jump) {
|
||||||
|
const i = dir < 0 ? 0 : all.length - 1;
|
||||||
|
jumpTo(all[i], i, all.length, 'Section');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir < 0) {
|
||||||
|
for (let i = all.length - 1; i >= 0; i--)
|
||||||
|
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||||
|
announce('Beginning of sections');
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < all.length; i++)
|
||||||
|
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||||
|
announce('End of sections');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initNav() {
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (key === 'o') { openClipInFile(); return; }
|
||||||
|
navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Previous/next track = J/K. Works from AirPods, the iOS lock screen and
|
||||||
|
// Control Center, and the media keys on a Windows keyboard.
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) {
|
||||||
|
try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); }
|
||||||
|
catch { /* action not supported here */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// The per-row full-file player and its cut fields. Section review normally
|
||||||
|
// goes through clips.js; this is for listening around a section ("Open in
|
||||||
|
// file") or to a whole recording.
|
||||||
|
|
||||||
|
import {$, fmtDur} from './util.js';
|
||||||
|
import {fileByIdx, fileSpan} from './store.js';
|
||||||
|
import {streamUrl} from './api.js';
|
||||||
|
import {getPreroll} from './settings.js';
|
||||||
|
|
||||||
|
// Row of the full-file player opened last. The J/K/U/I fallback steps through
|
||||||
|
// its sections when no clip queue is armed. Live binding — read, don't assign.
|
||||||
|
export let activePlayerIdx = null;
|
||||||
|
|
||||||
|
export function forgetPlayer(idx) {
|
||||||
|
if (activePlayerIdx === idx) activePlayerIdx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCutFields(idx, startSec, endSec) {
|
||||||
|
const startEl = $('cut-start-' + idx);
|
||||||
|
const endEl = $('cut-end-' + idx);
|
||||||
|
if (startEl) startEl.value = fmtDur(startSec);
|
||||||
|
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = idx => $('pbtn-' + idx)?.getAttribute('aria-expanded') === 'true';
|
||||||
|
|
||||||
|
function setPlayButton(idx, open) {
|
||||||
|
const btn = $('pbtn-' + idx);
|
||||||
|
const f = fileByIdx(idx);
|
||||||
|
if (!btn || !f) return;
|
||||||
|
btn.setAttribute('aria-expanded', open);
|
||||||
|
btn.textContent = open ? 'Hide' : 'Play';
|
||||||
|
btn.setAttribute('aria-label', (open ? 'Hide player for ' : 'Play ') + fileSpan(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player lives in its own table cell: the Play button stays, and the
|
||||||
|
// audio element appears right after it
|
||||||
|
export function closePlayer(idx) {
|
||||||
|
const audio = $('aud-' + idx);
|
||||||
|
if (!audio || audio.hidden) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.hidden = true;
|
||||||
|
setPlayButton(idx, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePlayer(idx) {
|
||||||
|
if (isOpen(idx)) { closePlayer(idx); return; }
|
||||||
|
const f = fileByIdx(idx);
|
||||||
|
const audio = $('aud-' + idx);
|
||||||
|
if (!f || !audio) return;
|
||||||
|
if (!audio.dataset.srcSet) {
|
||||||
|
audio.preload = 'auto';
|
||||||
|
audio.src = streamUrl(f.name);
|
||||||
|
audio.load();
|
||||||
|
audio.dataset.srcSet = '1';
|
||||||
|
} else if (!$('rec-' + idx)?.hidden) {
|
||||||
|
// Still recording: re-fetch so duration and seek range cover the audio
|
||||||
|
// captured since the source was last loaded
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
activePlayerIdx = idx;
|
||||||
|
audio.hidden = false;
|
||||||
|
setPlayButton(idx, true);
|
||||||
|
audio.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seekToSection(idx, startSec, endSec) {
|
||||||
|
if (!isOpen(idx)) togglePlayer(idx);
|
||||||
|
activePlayerIdx = idx;
|
||||||
|
const audio = $('aud-' + idx);
|
||||||
|
if (!audio) return;
|
||||||
|
audio.preload = 'auto';
|
||||||
|
const seekTo = Math.max(0, startSec - getPreroll());
|
||||||
|
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||||||
|
if (audio.readyState >= 1) doSeek();
|
||||||
|
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||||
|
setCutFields(idx, startSec, endSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut button: show/hide the start/end fields inside the options cell. Opening
|
||||||
|
// puts focus in Start. Clip playback pre-fills the fields either way.
|
||||||
|
export function toggleCut(idx) {
|
||||||
|
const btn = $('cutbtn-' + idx);
|
||||||
|
const panel = $('cut-' + idx);
|
||||||
|
if (!btn || !panel) return;
|
||||||
|
const open = panel.hidden;
|
||||||
|
panel.hidden = !open;
|
||||||
|
btn.setAttribute('aria-expanded', open);
|
||||||
|
btn.querySelector('.day-arrow').textContent = open ? '▾ ' : '▸ ';
|
||||||
|
if (open) $('cut-start-' + idx)?.focus();
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// The settings panel: analysis params, pre-roll and the auto-advance mode.
|
||||||
|
|
||||||
|
import {$, remember} from './util.js';
|
||||||
|
|
||||||
|
// The three analysis controls, read straight from the DOM. Deliberately no
|
||||||
|
// hardcoded fallbacks: the inputs are seeded from /api/config at startup, so
|
||||||
|
// web.py's MARGIN_DB / MIN_GAP_SECONDS / MIN_DURATION_SECONDS stay the single
|
||||||
|
// source of truth. An empty input sends an empty param, which _qs_float()
|
||||||
|
// falls back to the server default for.
|
||||||
|
export const analysisParams = () => ({
|
||||||
|
margin: $('margin-input').value,
|
||||||
|
minGap: $('min-gap-input').value,
|
||||||
|
minDur: $('min-duration-input').value,
|
||||||
|
});
|
||||||
|
// Same values as one string; a highlights panel built with other values is
|
||||||
|
// stale and gets recomputed on the next expand
|
||||||
|
export const hlParams = () => Object.values(analysisParams()).join('|');
|
||||||
|
|
||||||
|
export const getPreroll = () => {
|
||||||
|
const v = parseFloat($('preroll-input').value);
|
||||||
|
return isNaN(v) || v < 0 ? 0 : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const advanceMode = () =>
|
||||||
|
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
|
||||||
|
|
||||||
|
// Does a server-side cached analysis match the current control values?
|
||||||
|
// Auto-loading on a mismatch would silently recompute every file.
|
||||||
|
export function cachedParamsMatch(ca) {
|
||||||
|
const p = analysisParams();
|
||||||
|
return ca != null
|
||||||
|
&& Number(ca.margin) === parseFloat(p.margin)
|
||||||
|
&& Number(ca.min_gap) === parseFloat(p.minGap)
|
||||||
|
&& Number(ca.min_duration) === parseFloat(p.minDur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the analysis params from /api/config, and restore the per-browser
|
||||||
|
// conveniences. Margin/gap/min-duration are not remembered client-side: a
|
||||||
|
// stale browser value would quietly disagree with the server's defaults.
|
||||||
|
export function initSettings(cfg) {
|
||||||
|
if (cfg.margin != null) $('margin-input').value = cfg.margin;
|
||||||
|
if (cfg.min_gap != null) $('min-gap-input').value = cfg.min_gap;
|
||||||
|
if (cfg.min_duration != null) $('min-duration-input').value = cfg.min_duration;
|
||||||
|
|
||||||
|
const pre = remember.get('preroll');
|
||||||
|
if (pre != null) $('preroll-input').value = pre;
|
||||||
|
$('preroll-input').addEventListener('change', e => remember.set('preroll', e.target.value));
|
||||||
|
|
||||||
|
const adv = remember.get('advance');
|
||||||
|
const radio = adv && document.querySelector(`input[name="clip-adv"][value="${adv}"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
document.querySelectorAll('input[name="clip-adv"]').forEach(r =>
|
||||||
|
r.addEventListener('change', () => remember.set('advance', r.value)));
|
||||||
|
|
||||||
|
const panel = $('settings');
|
||||||
|
panel.open = remember.get('settingsOpen') === '1';
|
||||||
|
panel.addEventListener('toggle', () => remember.set('settingsOpen', panel.open ? '1' : '0'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// The file listing and what the UI knows about each file. A leaf apart from
|
||||||
|
// util.js.
|
||||||
|
|
||||||
|
import {fileStartEpoch, fmtHM} from './util.js';
|
||||||
|
|
||||||
|
// Full listing from /api/files. Reassigned on every load; importers see the
|
||||||
|
// new array through the live binding.
|
||||||
|
export let allFiles = [];
|
||||||
|
|
||||||
|
// _idx is per filename and stable for the whole session: assigned the first
|
||||||
|
// time a name is seen and never reused. DOM ids (row-, pbtn-, aud-, …) are
|
||||||
|
// built from it, so they survive reloads — that is what lets a re-rendered day
|
||||||
|
// keep focus where it was. It used to be the position in the listing, which
|
||||||
|
// shifted on every rollover and left stored indexes pointing one row off.
|
||||||
|
const idxByName = new Map();
|
||||||
|
const stableIdx = name => {
|
||||||
|
if (!idxByName.has(name)) idxByName.set(name, idxByName.size);
|
||||||
|
return idxByName.get(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setFiles(list) {
|
||||||
|
allFiles = list.map(f => ({...f, _idx: stableIdx(f.name)}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFile(name) {
|
||||||
|
allFiles = allFiles.filter(f => f.name !== name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileByName = name => allFiles.find(f => f.name === name) ?? null;
|
||||||
|
export const fileByIdx = idx => allFiles.find(f => f._idx === idx) ?? null;
|
||||||
|
// Queue entries carry the filename, never a row index; resolve at use time.
|
||||||
|
// Null when the file is gone from the current listing.
|
||||||
|
export const fileIdxByName = name => fileByName(name)?._idx ?? null;
|
||||||
|
|
||||||
|
export const isAnalysable = f => f.ext === 'wav' || f.ext === 'flac';
|
||||||
|
|
||||||
|
// idx -> [{start, end, score}], populated by analyse()
|
||||||
|
export const sectionMap = new Map();
|
||||||
|
|
||||||
|
// What a recording is called wherever it is spoken: its wall-clock span from
|
||||||
|
// the filename clock, "22:00 to 23:00". The filename itself ("20260523_220000")
|
||||||
|
// reads as a string of numbers. Non-standard names fall back to the name.
|
||||||
|
export function fileSpan(f) {
|
||||||
|
const start = fileStartEpoch(f.date);
|
||||||
|
if (start == null) return f.name;
|
||||||
|
if (f.recording) return `${fmtHM(start)} to now`;
|
||||||
|
if (f.duration == null) return fmtHM(start);
|
||||||
|
return `${fmtHM(start)} to ${fmtHM(Math.round((start + f.duration) / 60) * 60)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Formatting and screen-reader helpers shared by every module. A leaf: it
|
||||||
|
// imports nothing, so any module can use it without creating a cycle.
|
||||||
|
|
||||||
|
export const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
export const esc = s => String(s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
export const pad = n => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
export const fmtDur = s => {
|
||||||
|
if (s == null) return '—';
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
|
||||||
|
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59"
|
||||||
|
// reads like a clock time
|
||||||
|
export const fmtDurWords = s => {
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||||
|
if (h) return m ? `${h} h ${m} min` : `${h} h`;
|
||||||
|
return m ? `${m} min` : `${Math.round(s)} s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fmtSize = b => {
|
||||||
|
if (b < 1024) return b + ' B';
|
||||||
|
if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB';
|
||||||
|
if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB';
|
||||||
|
return (b / (1 << 30)).toFixed(2) + ' GB';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Epoch seconds -> local wall-clock "HH:MM:SS"
|
||||||
|
export const fmtClock = ts => {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Epoch seconds -> local "HH:MM"
|
||||||
|
export const fmtHM = ts => {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
|
||||||
|
// the filename server-side) -> epoch seconds, or null for unparseable values
|
||||||
|
export const fileStartEpoch = date => {
|
||||||
|
const t = Date.parse(String(date).replace(' ', 'T'));
|
||||||
|
return isNaN(t) ? null : t / 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "1:30", "0:01:30" or "90" -> seconds; null when unparseable
|
||||||
|
export function parseTime(s) {
|
||||||
|
if (!s || !s.trim()) return null;
|
||||||
|
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||||||
|
if (parts.some(isNaN) || parts.length > 3) return null;
|
||||||
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the
|
||||||
|
// ISO date, which screen readers read digit group by digit group. en-GB to
|
||||||
|
// match the page's lang="en", so the speech language never switches mid-page.
|
||||||
|
export function dayLabel(day) {
|
||||||
|
const [y, m, d] = day.split('-').map(Number);
|
||||||
|
const date = new Date(y, m - 1, d);
|
||||||
|
if (isNaN(date)) return day;
|
||||||
|
const now = new Date();
|
||||||
|
const opts = {weekday: 'long', day: 'numeric', month: 'long'};
|
||||||
|
if (y !== now.getFullYear()) opts.year = 'numeric';
|
||||||
|
const text = date.toLocaleDateString('en-GB', opts);
|
||||||
|
// Math.round absorbs the 23/25 h days around DST switches
|
||||||
|
const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000);
|
||||||
|
return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function announce(msg) {
|
||||||
|
const el = $('sr-announce');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = ''; // clear first so same text re-triggers
|
||||||
|
setTimeout(() => { el.textContent = msg; }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long chip list is a wall of buttons: hide it behind an aria-expanded
|
||||||
|
// toggle. Returns the toggle to insert before the chips, or null when the list
|
||||||
|
// is short enough to show as is. The separator space lives inside the
|
||||||
|
// aria-hidden arrow — a leading space in the text node reads as an indent.
|
||||||
|
export function chipToggle(chips, text) {
|
||||||
|
if (chips.children.length <= 12) return null;
|
||||||
|
chips.hidden = true;
|
||||||
|
const tog = document.createElement('button');
|
||||||
|
tog.className = 'chip-toggle';
|
||||||
|
tog.setAttribute('aria-expanded', 'false');
|
||||||
|
const arrow = document.createElement('span');
|
||||||
|
arrow.className = 'day-arrow';
|
||||||
|
arrow.setAttribute('aria-hidden', 'true');
|
||||||
|
arrow.textContent = '▸ ';
|
||||||
|
tog.append(arrow, text);
|
||||||
|
tog.addEventListener('click', () => {
|
||||||
|
const exp = chips.hidden;
|
||||||
|
chips.hidden = !exp;
|
||||||
|
tog.setAttribute('aria-expanded', exp);
|
||||||
|
arrow.textContent = exp ? '▾ ' : '▸ ';
|
||||||
|
});
|
||||||
|
return tog;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage for per-browser conveniences (pre-roll, auto-advance, whether
|
||||||
|
// the settings are open). It can be missing or throw (private mode, blocked
|
||||||
|
// storage), and nothing here may depend on it working.
|
||||||
|
export const remember = {
|
||||||
|
get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } },
|
||||||
|
set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } },
|
||||||
|
};
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#2c3040;--txt:#e2e8f0;--muted:#8b93ad;
|
||||||
|
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;
|
||||||
|
--gutter:28px}
|
||||||
|
@media (max-width:640px){:root{--gutter:16px}}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
/* One rule for every [hidden] element. Author display rules (flex chips, the
|
||||||
|
inline-flex REC badge, the flex clip bar) beat the UA's [hidden] otherwise —
|
||||||
|
the REC badge used to show on every row that way. */
|
||||||
|
[hidden]{display:none !important}
|
||||||
|
body{background:var(--bg);color:var(--txt);font:15px/1.5 system-ui,sans-serif}
|
||||||
|
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
|
||||||
|
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||||
|
.skip{position:absolute;left:-999px;top:0;padding:8px 14px;background:var(--accent);
|
||||||
|
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-weight:600;z-index:30}
|
||||||
|
.skip:focus{left:0}
|
||||||
|
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
#page-title:focus,#days:focus{outline:none}
|
||||||
|
.muted{color:var(--muted)}
|
||||||
|
.nowrap{white-space:nowrap}
|
||||||
|
|
||||||
|
header{padding:16px var(--gutter);border-bottom:1px solid var(--brd);
|
||||||
|
display:flex;align-items:baseline;gap:6px 14px;flex-wrap:wrap}
|
||||||
|
header h1{font-size:20px;font-weight:600}
|
||||||
|
#subtitle{color:var(--muted)}
|
||||||
|
#storage-info{color:var(--muted);font-size:13px;margin-right:auto}
|
||||||
|
|
||||||
|
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);color:var(--txt);
|
||||||
|
padding:6px 12px;border-radius:6px;font:inherit;font-size:13px;white-space:nowrap;min-height:32px}
|
||||||
|
button:hover{background:var(--brd)}
|
||||||
|
button:disabled,button[aria-disabled=true]{opacity:.55;cursor:default}
|
||||||
|
input{font:inherit;font-size:14px;background:var(--bg);border:1px solid var(--brd);
|
||||||
|
color:var(--txt);padding:5px 8px;border-radius:5px;min-height:32px}
|
||||||
|
input[type=date]{color-scheme:dark}
|
||||||
|
input[type=radio]{min-height:0;accent-color:var(--accent)}
|
||||||
|
|
||||||
|
/* settings */
|
||||||
|
#settings{border-bottom:1px solid var(--brd);background:var(--surf)}
|
||||||
|
#settings summary{padding:10px var(--gutter);cursor:pointer;color:var(--muted);font-size:14px}
|
||||||
|
#settings summary:hover{color:var(--txt)}
|
||||||
|
.settings-grid{display:grid;grid-template-columns:max-content 90px 1fr;gap:8px 12px;
|
||||||
|
align-items:center;padding:4px var(--gutter) 14px}
|
||||||
|
.settings-grid label{font-size:14px}
|
||||||
|
.hint{font-size:12px;color:var(--muted)}
|
||||||
|
@media (max-width:640px){
|
||||||
|
.settings-grid{grid-template-columns:1fr 90px}
|
||||||
|
.settings-grid .hint{grid-column:1 / -1;margin-top:-6px}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* filters */
|
||||||
|
.filter-bar{display:flex;align-items:center;gap:8px 10px;padding:10px var(--gutter);
|
||||||
|
border-bottom:1px solid var(--brd);flex-wrap:wrap}
|
||||||
|
.filter-bar label{font-size:14px;color:var(--muted)}
|
||||||
|
#filter-name{width:200px}
|
||||||
|
|
||||||
|
/* days */
|
||||||
|
#days{padding:20px var(--gutter)}
|
||||||
|
.day-section{margin-bottom:18px}
|
||||||
|
.day-heading-bar{background:var(--surf);padding:6px 10px;border:1px solid var(--brd);
|
||||||
|
border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.day-heading-bar.open{border-radius:8px 8px 0 0}
|
||||||
|
h2.day-heading{font-size:inherit;font-weight:inherit;flex:1 1 auto}
|
||||||
|
.day-toggle{background:none;border:none;color:var(--txt);font-size:15px;font-weight:600;
|
||||||
|
padding:4px 0;display:inline-flex;align-items:baseline;gap:4px 10px;flex-wrap:wrap;text-align:left;white-space:normal}
|
||||||
|
.day-toggle:hover{background:none;color:var(--accent)}
|
||||||
|
.day-meta{color:var(--muted);font-size:13px;font-weight:400}
|
||||||
|
.day-arrow{font-size:10px}
|
||||||
|
button.day-hl{color:var(--green);border-color:#166534;background:#052e16}
|
||||||
|
button.day-hl:hover{background:#0a3d1f}
|
||||||
|
.day-hl-status{opacity:.8}
|
||||||
|
.day-hl-container{border:1px solid var(--brd);border-top:none;padding:10px 12px 12px}
|
||||||
|
|
||||||
|
/* recordings table; scrolls sideways on narrow screens instead of the page */
|
||||||
|
.table-wrap{overflow-x:auto;border:1px solid var(--brd);border-top:none}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle;text-align:left}
|
||||||
|
thead th{color:var(--muted);font-weight:500;font-size:13px;white-space:nowrap}
|
||||||
|
th.when{font-weight:600;white-space:nowrap}
|
||||||
|
tr.data-row:hover>*{background:var(--surf)}
|
||||||
|
td.file{color:var(--muted);font-size:12px;white-space:nowrap}
|
||||||
|
.fn{font-family:ui-monospace,monospace}
|
||||||
|
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
|
||||||
|
font-weight:700;border:1px solid}
|
||||||
|
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
||||||
|
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||||
|
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
||||||
|
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
||||||
|
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
||||||
|
.badge-rec{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;
|
||||||
|
font-size:11px;font-weight:700;color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
|
||||||
|
animation:pulse 1.5s ease-in-out infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}}
|
||||||
|
.actions{display:flex;gap:6px;align-items:center}
|
||||||
|
a.dl{color:var(--accent);text-decoration:none;font-size:13px;padding:6px 4px}
|
||||||
|
a.dl:hover{text-decoration:underline}
|
||||||
|
button.del{color:var(--red);border-color:#7f1d1d}
|
||||||
|
button.del:hover:not(:disabled){background:#2d0808}
|
||||||
|
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||||
|
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||||||
|
|
||||||
|
/* analysis */
|
||||||
|
td.loudness{min-width:200px}
|
||||||
|
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:8px 10px}
|
||||||
|
.analysis-meta{font-size:12px;color:var(--muted)}
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||||
|
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:5px;
|
||||||
|
padding:4px 9px;font-size:12px;font-family:ui-monospace,monospace;min-height:30px}
|
||||||
|
.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||||||
|
.chip-toggle{margin-top:8px}
|
||||||
|
button.reanalyse-btn{margin-top:8px;font-size:12px;color:var(--muted);background:transparent}
|
||||||
|
.quiet{color:var(--muted);font-size:13px}
|
||||||
|
.quiet+.quiet{margin-top:6px}
|
||||||
|
.prog-bar{height:4px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
|
||||||
|
.prog-fill{height:100%;width:0;background:var(--accent);transition:width .15s ease}
|
||||||
|
@media (prefers-reduced-motion:reduce){.prog-fill{transition:none}}
|
||||||
|
.prog-file{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.empty{text-align:center;padding:60px 16px;color:var(--muted)}
|
||||||
|
|
||||||
|
/* full-file player cell, options cell */
|
||||||
|
audio{width:100%;height:40px;display:block;color-scheme:dark;accent-color:var(--accent)}
|
||||||
|
td.player audio{width:300px;margin-top:6px}
|
||||||
|
.cut-panel{display:flex;align-items:center;gap:8px 12px;margin-top:8px;flex-wrap:wrap}
|
||||||
|
.cut-field{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||||||
|
.cut-time{width:110px;font-family:ui-monospace,monospace}
|
||||||
|
|
||||||
|
/* clip player bar */
|
||||||
|
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
|
||||||
|
border-top:1px solid var(--brd);padding:8px var(--gutter);display:flex;flex-direction:column;gap:6px}
|
||||||
|
.clip-row{display:flex;align-items:center;gap:8px 10px;flex-wrap:wrap}
|
||||||
|
#clip-label{flex:1 1 240px;font-family:ui-monospace,monospace;font-size:13px;min-width:0}
|
||||||
|
#clip-audio{flex:1 1 260px;min-width:200px;width:auto}
|
||||||
|
#clip-adv{border:none;display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
|
||||||
|
#clip-adv legend{float:left;font-size:12px;color:var(--muted);margin-right:4px}
|
||||||
|
#clip-adv label{font-size:13px;display:flex;align-items:center;gap:5px;cursor:pointer}
|
||||||
|
body.clip-open{padding-bottom:130px}
|
||||||
Reference in New Issue
Block a user