Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 476a8d2752 | |||
| c3575c712e |
@@ -157,6 +157,7 @@ Shows a table of all recordings sorted newest-first. Features:
|
|||||||
|
|
||||||
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support.
|
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support.
|
||||||
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent.
|
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent.
|
||||||
|
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and removes the row without a full page reload.
|
||||||
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`.
|
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`.
|
||||||
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
||||||
|
|
||||||
@@ -203,7 +204,7 @@ docker compose down && docker compose up -d --build
|
|||||||
|
|
||||||
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
|
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
|
||||||
|
|
||||||
**File retention:** ISR never deletes recordings. Add a cron job on the host if needed:
|
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
|
||||||
```bash
|
```bash
|
||||||
# Delete recordings older than 30 days
|
# Delete recordings older than 30 days
|
||||||
find recordings/ -type f -mtime +30 -delete
|
find recordings/ -type f -mtime +30 -delete
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
# ISR Roadmap
|
||||||
|
|
||||||
|
## notify.py — NTFY Loudness Notifications
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Street ambience recorder. Goal: detect notable audio events (speech, thunder,
|
||||||
|
sustained unusual sounds) in hourly recording files and push a notification via
|
||||||
|
a self-hosted NTFY server. Generic short events (car horn, passing vehicle)
|
||||||
|
should be filtered out by a minimum section duration.
|
||||||
|
|
||||||
|
### Design decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Detection | RMS + minimum section duration filter (KISS — no FFT for now) |
|
||||||
|
| Timing | Configurable: `immediate` / `daily` / `both` |
|
||||||
|
| Config | `[notify]` section in existing `config.ini` |
|
||||||
|
| Code structure | `notify.py` imports `analyze_wav` / `analyze_flac` from `web.py` (DRY) |
|
||||||
|
| Source name | Included in notification body; configurable display name per source |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config additions (`config.example.ini`)
|
||||||
|
|
||||||
|
Add a `[notify]` section to `config.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[notify]
|
||||||
|
enabled = true
|
||||||
|
ntfy_url = https://ntfy.example.com/mytopic ; full URL incl. topic
|
||||||
|
mode = immediate ; immediate | daily | both
|
||||||
|
daily_time = 08:00 ; HH:MM — used in daily and both modes
|
||||||
|
debounce_minutes = 60 ; immediate mode: suppress repeat notifications within this window
|
||||||
|
min_section_duration = 2.0 ; seconds — sections shorter than this are ignored (filters car horns etc.)
|
||||||
|
min_sections = 1 ; number of qualifying sections required to trigger a notification
|
||||||
|
loudness_threshold = 0.05 ; RMS 0–1, same scale as web.py analysis threshold
|
||||||
|
```
|
||||||
|
|
||||||
|
Per recording source, add an optional `display_name`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[radio1]
|
||||||
|
type = stream
|
||||||
|
url = http://icecast.example.com:8000/live
|
||||||
|
display_name = Street mic north ; shown in notification; defaults to section name [radio1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notification format
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: ISR — Notable audio · Street mic north
|
||||||
|
Body: radio1_20260427_0300.wav
|
||||||
|
3 notable sections (≥ 2.0 s each)
|
||||||
|
→ 00:12 – 00:18
|
||||||
|
→ 01:45 – 01:52
|
||||||
|
→ 47:03 – 47:11
|
||||||
|
Peak RMS: 0.312
|
||||||
|
```
|
||||||
|
|
||||||
|
Daily digest example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: ISR Daily Digest · 2026-04-27
|
||||||
|
Body: Street mic north — 4 files with notable events
|
||||||
|
03:00 file · 3 sections (peak 0.312)
|
||||||
|
07:00 file · 1 section (peak 0.091)
|
||||||
|
14:00 file · 2 sections (peak 0.204)
|
||||||
|
21:00 file · 1 section (peak 0.178)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Implementation plan
|
||||||
|
|
||||||
|
#### Phase 1 — Core
|
||||||
|
|
||||||
|
1. **`config.example.ini`** — add `[notify]` section and `display_name` key to
|
||||||
|
source section examples (as shown above).
|
||||||
|
|
||||||
|
2. **`notify.py` — file watcher**
|
||||||
|
- Polls `recordings/status.json` every 30 s.
|
||||||
|
- Tracks which files were in `active` on the previous poll.
|
||||||
|
- When a file disappears from `active` it was just closed → queues it for
|
||||||
|
analysis.
|
||||||
|
- Skips files with extensions that cannot be analysed (anything other than
|
||||||
|
`.wav` / `.flac`).
|
||||||
|
|
||||||
|
3. **`notify.py` — analysis + filter**
|
||||||
|
- Imports `analyze_wav` / `analyze_flac` from `web.py`.
|
||||||
|
- Applies `loudness_threshold` from `[notify]` config.
|
||||||
|
- Filters resulting sections to those with duration ≥ `min_section_duration`.
|
||||||
|
- Counts filtered sections against `min_sections` threshold.
|
||||||
|
|
||||||
|
4. **`notify.py` — NTFY HTTP POST**
|
||||||
|
- Plain `urllib` POST to `ntfy_url` (no extra dependencies).
|
||||||
|
- Sets `Title` and message body as described above.
|
||||||
|
- Logs success / failure to stdout.
|
||||||
|
|
||||||
|
#### Phase 2 — Cadence modes
|
||||||
|
|
||||||
|
5. **Immediate mode with debounce**
|
||||||
|
- Fires right after the file closes and analysis passes.
|
||||||
|
- Persists last-notification timestamp per source to a small
|
||||||
|
`notify_state.json` in the recordings directory.
|
||||||
|
- Suppresses sending if last notification for that source was within
|
||||||
|
`debounce_minutes`.
|
||||||
|
|
||||||
|
6. **Daily digest mode**
|
||||||
|
- Appends qualifying events to `notify_log.jsonl` in the recordings
|
||||||
|
directory (one JSON line per event: timestamp, source, filename, sections,
|
||||||
|
peak RMS).
|
||||||
|
- On each poll checks whether `daily_time` has passed today and no digest
|
||||||
|
has been sent yet (tracked in `notify_state.json`).
|
||||||
|
- Reads all undigested entries from `notify_log.jsonl`, groups by
|
||||||
|
`display_name`, sends one notification per source with notable activity.
|
||||||
|
- Marks entries as digested.
|
||||||
|
|
||||||
|
7. **Both mode**
|
||||||
|
- Immediate path: only fires when peak RMS exceeds a second, higher
|
||||||
|
threshold (`alarm_threshold`, default `0.3`; add to `[notify]` config).
|
||||||
|
- Daily digest path: fires for everything that passes `min_sections`.
|
||||||
|
|
||||||
|
#### Phase 3 — Integration
|
||||||
|
|
||||||
|
8. **Docker** — optional `notify` service in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
build: .
|
||||||
|
command: python notify.py
|
||||||
|
volumes:
|
||||||
|
- ./recordings:/app/recordings
|
||||||
|
- ./config.ini:/app/config.ini:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **README** — new section documenting `notify.py` usage, config keys, and
|
||||||
|
Docker setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions (decide before implementing)
|
||||||
|
|
||||||
|
- **Log rotation**: `notify_log.jsonl` grows indefinitely. Options: cap at N
|
||||||
|
days (configurable), cap at N MB, or leave cleanup to the user. No decision
|
||||||
|
made yet.
|
||||||
|
- **Multiple NTFY topics per source**: current design uses one global topic.
|
||||||
|
If per-source topics are ever needed, `ntfy_url` could be moved to the source
|
||||||
|
section and override the global one.
|
||||||
|
- **FFT / frequency analysis** (future): distinguishing thunder (low rumble,
|
||||||
|
50–200 Hz) from speech (300–3000 Hz) from vehicles would reduce false
|
||||||
|
positives further. Deferred — requires `numpy` and adds meaningful complexity.
|
||||||
@@ -249,6 +249,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
threshold: float = LOUD_THRESHOLD
|
threshold: float = LOUD_THRESHOLD
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
p = parsed.path
|
||||||
|
if p.startswith('/api/files/'):
|
||||||
|
self._api_delete(unquote(p[len('/api/files/'):]))
|
||||||
|
else:
|
||||||
|
self._send(404, b'Not found', '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)
|
||||||
@@ -419,6 +427,28 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
data = json.dumps({'threshold': self.threshold})
|
data = json.dumps({'threshold': self.threshold})
|
||||||
self._send(200, data.encode(), 'application/json')
|
self._send(200, data.encode(), 'application/json')
|
||||||
|
|
||||||
|
def _api_delete(self, filename: str):
|
||||||
|
status_path = Path(self.recordings_dir) / 'status.json'
|
||||||
|
try:
|
||||||
|
with open(status_path) as fh:
|
||||||
|
if filename in set(json.load(fh).get('active', [])):
|
||||||
|
self._json_err(409, 'Cannot delete a file that is currently being recorded')
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
path = self._safe_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
self._json_err(500, f'Failed to delete: {e}')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
||||||
|
|
||||||
def _safe_path(self, filename: str):
|
def _safe_path(self, filename: str):
|
||||||
base = Path(self.recordings_dir).resolve()
|
base = Path(self.recordings_dir).resolve()
|
||||||
try:
|
try:
|
||||||
@@ -512,6 +542,8 @@ a.dl{color:var(--accent);text-decoration:none;font-size:13px}
|
|||||||
a.dl:hover{text-decoration:underline}
|
a.dl:hover{text-decoration:underline}
|
||||||
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||||||
.actions{display:flex;gap:6px;align-items:center}
|
.actions{display:flex;gap:6px;align-items:center}
|
||||||
|
button.del{color:var(--red);border-color:#7f1d1d}
|
||||||
|
button.del:hover:not(:disabled){background:#2d0808}
|
||||||
/* waveform */
|
/* waveform */
|
||||||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
||||||
svg.wave{display:block;width:100%;height:56px}
|
svg.wave{display:block;width:100%;height:56px}
|
||||||
@@ -686,6 +718,33 @@ async function analyse(filename, cell, btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteFile(idx, filename) {
|
||||||
|
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
||||||
|
const btn = document.getElementById('delbtn-'+idx);
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
||||||
|
if (r.ok) {
|
||||||
|
document.getElementById('row-'+idx)?.remove();
|
||||||
|
document.getElementById('prow-'+idx)?.remove();
|
||||||
|
recMap.delete(idx);
|
||||||
|
const remaining = document.querySelectorAll('tr.data-row').length;
|
||||||
|
document.getElementById('subtitle').textContent =
|
||||||
|
`${remaining} recording${remaining!==1?'s':''} found`;
|
||||||
|
if (!remaining) document.getElementById('empty').style.display = '';
|
||||||
|
updateStorage();
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(()=>({}));
|
||||||
|
alert('Delete failed: '+(d.error||r.statusText));
|
||||||
|
btn.disabled = false; btn.textContent = '✕ Delete';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Delete failed: '+e.message);
|
||||||
|
btn.disabled = false; btn.textContent = '✕ Delete';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateStorage() {
|
async function updateStorage() {
|
||||||
try {
|
try {
|
||||||
const s = await (await fetch('/api/storage')).json();
|
const s = await (await fetch('/api/storage')).json();
|
||||||
@@ -753,6 +812,9 @@ async function load() {
|
|||||||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||||||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||||||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||||||
|
<button id="delbtn-${i}" class="del"
|
||||||
|
aria-label="Delete ${esc(f.name)}"
|
||||||
|
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>`;
|
</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -787,6 +849,12 @@ async function load() {
|
|||||||
document.getElementById('pbtn-'+i)
|
document.getElementById('pbtn-'+i)
|
||||||
.addEventListener('click', () => togglePlayer(i, f.name));
|
.addEventListener('click', () => togglePlayer(i, f.name));
|
||||||
|
|
||||||
|
// ---- attach delete button handler ----
|
||||||
|
if (!isRec) {
|
||||||
|
document.getElementById('delbtn-'+i)
|
||||||
|
.addEventListener('click', () => deleteFile(i, f.name));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- register for live-status polling ----
|
// ---- register for live-status polling ----
|
||||||
recMap.set(i, f.name);
|
recMap.set(i, f.name);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user