Compare commits

...

10 Commits

Author SHA1 Message Date
admin 47ce682821 fix: K key re-jumping to same section instead of advancing
The forward-skip threshold was `cur + 0.5`, which is smaller than
the preroll offset applied when landing on a section. After K placed
the cursor at `s.start - preroll`, the next K press found the same
section again because `s.start > (s.start - preroll) + 0.5` is true
whenever preroll > 0.5. Using `cur + preroll` as the threshold ensures
the current section's start is not > cur + preroll, so only the next
section matches.
2026-04-29 21:06:02 +02:00
admin a70701f260 fix: cut metadata, J/K cut fields, screen reader section announcements
Cut metadata: WAV and FLAC are now re-encoded (pcm_s16le / flac)
instead of stream-copied, so the container header is always rewritten
with the correct duration. Lossy formats keep -c copy.

J/K shortcuts: now also update the cut panel start/end fields to match
the section they landed on, and announce the section aloud.

Screen reader: added aria-live polite region; chip clicks and J/K
announce 'Section N of M: start to end'; boundary presses announce
'Beginning of sections' or 'End of sections'.

Pre-roll: configurable 'Pre-roll' seconds input in the controls bar
(default 3 s); all section seeks subtract the pre-roll from the target
position so the listener hears context before the loud section begins.
2026-04-29 20:56:51 +02:00
admin abfe81e734 docs: update README for all new web UI features 2026-04-29 20:41:39 +02:00
admin eff9240b5d feat: add ffmpeg audio trim / cut download
Adds a Cut panel inside every player row (✂ Cut button in the actions
column opens the row and focuses the Start field). Users type start and
end times in m:ss or h:mm:ss format; Download cut triggers
GET /api/cut which runs ffmpeg -c copy (no re-encode) and streams the
result as an attachment.

Clicking an analysis chip now also pre-fills the cut panel start/end
with the loud-section boundaries.

Server switched to ThreadingHTTPServer so ffmpeg runs don't block
other requests. ffmpeg added to Dockerfile apt install.
2026-04-29 20:41:18 +02:00
admin de667821b7 feat: add filename and date-range filters to recordings list
Client-side filter bar with live filename search and from/to date
pickers. Rendering is split into renderFiles() + applyFilters() so
filters can be re-applied without re-fetching. Subtitle shows
'N of M shown' when a filter is active. Clear button resets all fields.
2026-04-29 20:37:14 +02:00
admin d583620f8c feat: click loud-section chips to seek audio; J/K keyboard shortcuts
Analysis chips are now buttons. Clicking one opens the player (if
not already open) and seeks to the section start. J skips to the
previous loud section, K to the next. Shortcuts are suppressed when
focus is inside an input field.
2026-04-29 20:33:27 +02:00
admin 6d16b2c0a3 fix: resolve FLAC audio player showing 00:00 duration
With preload=none the browser never fetches metadata, so Chrome
cannot populate the duration field for FLAC files. On player open:
set preload=metadata and call audio.load() to trigger a metadata-only
fetch. Also render a server-computed duration label beneath the audio
element as a fallback for formats the browser cannot parse.
2026-04-29 20:25:05 +02:00
admin 7db0e0870f fix: skip duration read for active recordings to prevent garbage values
WAV nframes and FLAC total_samples are both unfinalized while the
recorder has the file open, producing wildly wrong durations
(e.g. 53375995583:39:01). Return None (shown as —) instead.
2026-04-29 19:54:38 +02:00
admin 476a8d2752 docs: add ROADMAP.md for notify.py NTFY notification feature 2026-04-27 00:20:03 +02:00
admin c3575c712e feat: add delete button for recordings in web UI
Adds a DELETE /api/files/<name> endpoint that refuses to remove files
currently being recorded (409). The UI shows a red '✕ Delete' button per
row (disabled while REC), confirms before proceeding, and removes both
the data row and the hidden player row from the DOM on success without
a full page reload. README updated accordingly.
2026-04-27 00:14:56 +02:00
4 changed files with 562 additions and 39 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
alsa-utils \ alsa-utils \
ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+7 -3
View File
@@ -155,9 +155,13 @@ python web.py --threshold 0.03 # loudness threshold 01 (default 0.05)
Shows a table of all recordings sorted newest-first. Features: 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. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. - **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.
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. - **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing.
- **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).
- **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 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`. Duration for in-progress files shows `—` (header is unfinalized until recording stops).
- **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 +207,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
View File
@@ -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 01, same scale as web.py analysis threshold
```
Per recording source, add an optional `display_name`:
```ini
[radio1]
type = stream
url = http://icecast.example.com:8000/live
display_name = Street mic north ; shown in notification; defaults to section name [radio1]
```
---
### Notification format
```
Title: ISR — Notable audio · Street mic north
Body: radio1_20260427_0300.wav
3 notable sections (≥ 2.0 s each)
→ 00:12 00:18
→ 01:45 01:52
→ 47:03 47:11
Peak RMS: 0.312
```
Daily digest example:
```
Title: ISR Daily Digest · 2026-04-27
Body: Street mic north — 4 files with notable events
03:00 file · 3 sections (peak 0.312)
07:00 file · 1 section (peak 0.091)
14:00 file · 2 sections (peak 0.204)
21:00 file · 1 section (peak 0.178)
```
---
### Implementation plan
#### Phase 1 — Core
1. **`config.example.ini`** — add `[notify]` section and `display_name` key to
source section examples (as shown above).
2. **`notify.py` — file watcher**
- Polls `recordings/status.json` every 30 s.
- Tracks which files were in `active` on the previous poll.
- When a file disappears from `active` it was just closed → queues it for
analysis.
- Skips files with extensions that cannot be analysed (anything other than
`.wav` / `.flac`).
3. **`notify.py` — analysis + filter**
- Imports `analyze_wav` / `analyze_flac` from `web.py`.
- Applies `loudness_threshold` from `[notify]` config.
- Filters resulting sections to those with duration ≥ `min_section_duration`.
- Counts filtered sections against `min_sections` threshold.
4. **`notify.py` — NTFY HTTP POST**
- Plain `urllib` POST to `ntfy_url` (no extra dependencies).
- Sets `Title` and message body as described above.
- Logs success / failure to stdout.
#### Phase 2 — Cadence modes
5. **Immediate mode with debounce**
- Fires right after the file closes and analysis passes.
- Persists last-notification timestamp per source to a small
`notify_state.json` in the recordings directory.
- Suppresses sending if last notification for that source was within
`debounce_minutes`.
6. **Daily digest mode**
- Appends qualifying events to `notify_log.jsonl` in the recordings
directory (one JSON line per event: timestamp, source, filename, sections,
peak RMS).
- On each poll checks whether `daily_time` has passed today and no digest
has been sent yet (tracked in `notify_state.json`).
- Reads all undigested entries from `notify_log.jsonl`, groups by
`display_name`, sends one notification per source with notable activity.
- Marks entries as digested.
7. **Both mode**
- Immediate path: only fires when peak RMS exceeds a second, higher
threshold (`alarm_threshold`, default `0.3`; add to `[notify]` config).
- Daily digest path: fires for everything that passes `min_sections`.
#### Phase 3 — Integration
8. **Docker** — optional `notify` service in `docker-compose.yml`:
```yaml
notify:
build: .
command: python notify.py
volumes:
- ./recordings:/app/recordings
- ./config.ini:/app/config.ini:ro
restart: unless-stopped
```
9. **README** — new section documenting `notify.py` usage, config keys, and
Docker setup.
---
### Open questions (decide before implementing)
- **Log rotation**: `notify_log.jsonl` grows indefinitely. Options: cap at N
days (configurable), cap at N MB, or leave cleanup to the user. No decision
made yet.
- **Multiple NTFY topics per source**: current design uses one global topic.
If per-source topics are ever needed, `ntfy_url` could be moved to the source
section and override the global one.
- **FFT / frequency analysis** (future): distinguishing thunder (low rumble,
50200 Hz) from speech (3003000 Hz) from vehicles would reduce false
positives further. Deferred — requires `numpy` and adds meaningful complexity.
+400 -36
View File
@@ -19,9 +19,11 @@ import os
import re import re
import shutil import shutil
import struct import struct
import subprocess
import tempfile
import wave import wave
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer, 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
@@ -222,10 +224,14 @@ def list_files(recordings_dir: str):
for path in base.rglob('*'): for path in base.rglob('*'):
if path.suffix.lower() not in AUDIO_EXTENSIONS: if path.suffix.lower() not in AUDIO_EXTENSIONS:
continue continue
stat = path.stat() stat = path.stat()
rel = str(path.relative_to(base)).replace('\\', '/') rel = str(path.relative_to(base)).replace('\\', '/')
is_active = rel in active_files
duration = _get_audio_duration(path) # Skip reading partial headers for in-progress files — the WAV nframes
# field and FLAC total_samples are both unfinalized while recording,
# producing wildly incorrect values (e.g. 53375995583:39:01).
duration = None if is_active else _get_audio_duration(path)
files.append({ files.append({
'name': rel, 'name': rel,
@@ -234,7 +240,7 @@ def list_files(recordings_dir: str):
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), 'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration, 'duration': duration,
'ext': path.suffix.lower().lstrip('.'), 'ext': path.suffix.lower().lstrip('.'),
'recording': rel in active_files, 'recording': is_active,
}) })
files.sort(key=lambda f: f['mtime'], reverse=True) files.sort(key=lambda f: f['mtime'], reverse=True)
@@ -249,6 +255,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)
@@ -266,6 +280,8 @@ class _Handler(BaseHTTPRequestHandler):
self._api_storage() self._api_storage()
elif p == '/api/config': elif p == '/api/config':
self._api_config() self._api_config()
elif p == '/api/cut':
self._api_cut(qs)
elif p.startswith('/download/'): elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):])) self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'): elif p.startswith('/stream/'):
@@ -419,6 +435,108 @@ 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 _api_cut(self, qs):
filename = qs.get('file', [None])[0]
start_s = qs.get('start', [None])[0]
end_s = qs.get('end', [None])[0]
if not filename or start_s is None or end_s is None:
self._json_err(400, 'missing file, start, or end parameter')
return
try:
start = float(start_s)
end = float(end_s)
except (ValueError, TypeError):
self._json_err(400, 'start and end must be numbers (seconds)')
return
if start < 0 or end <= start:
self._json_err(400, 'start must be ≥ 0 and end must be > start')
return
path = self._safe_path(filename)
if path is None:
return
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 cut a file that is currently being recorded')
return
except Exception:
pass
if not shutil.which('ffmpeg'):
self._json_err(500, 'ffmpeg is not available on this server')
return
ext = path.suffix.lower()
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
# For lossless formats, re-encode (not copy) so the container header
# is rewritten with the correct duration/size. For lossy formats,
# copy is fine — the audio stops at the right frame regardless.
_lossless = {'.wav': ['-c:a', 'pcm_s16le'], '.flac': ['-c:a', 'flac']}
codec_args = _lossless.get(ext, ['-c', 'copy'])
fd, tmp_path = tempfile.mkstemp(suffix=ext)
os.close(fd)
try:
cmd = ['ffmpeg', '-y',
'-i', str(path),
'-ss', str(start), '-to', str(end),
'-vn'] + codec_args + [tmp_path]
result = subprocess.run(cmd, capture_output=True, timeout=120)
if result.returncode != 0:
err = result.stderr.decode('utf-8', errors='replace')[-400:]
self._json_err(500, f'ffmpeg error: {err}')
return
tmp_size = os.path.getsize(tmp_path)
content_type = MIME_TYPES.get(ext, 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Disposition', f'attachment; filename="{out_name}"')
self.send_header('Content-Length', str(tmp_size))
self.end_headers()
with open(tmp_path, 'rb') as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except subprocess.TimeoutExpired:
self._json_err(504, 'ffmpeg timed out — file may be too large')
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
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,12 +630,17 @@ 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}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px} .chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px; .chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace} padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
button.chip{cursor:pointer}
button.chip:hover{background:#6c1f08;border-color:#9a3412}
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.quiet{color:var(--muted);font-size:12px;margin-top:6px} .quiet{color:var(--muted);font-size:12px;margin-top:6px}
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0} .spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty{text-align:center;padding:60px;color:var(--muted)} .empty{text-align:center;padding:60px;color:var(--muted)}
@@ -525,9 +648,29 @@ svg.wave{display:block;width:100%;height:56px}
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)} .player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
audio{width:100%;height:36px;border-radius:4px;display:block; audio{width:100%;height:36px;border-radius:4px;display:block;
color-scheme:dark;accent-color:var(--accent)} color-scheme:dark;accent-color:var(--accent)}
/* cut panel */
.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;
padding-top:8px;border-top:1px solid var(--brd)}
.cut-label{font-size:12px;color:var(--muted);white-space:nowrap}
.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)}
.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt);
padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace}
.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px}
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
button.cut:hover:not(:disabled){background:#1e3a8a}
/* filter bar */
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
</style> </style>
</head> </head>
<body> <body>
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
<a href="#main" class="skip">Skip to content</a> <a href="#main" class="skip">Skip to content</a>
<header> <header>
<h1>ISR Archive</h1> <h1>ISR Archive</h1>
@@ -540,6 +683,19 @@ audio{width:100%;height:36px;border-radius:4px;display:block;
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05" <input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
aria-describedby="threshold-hint"> aria-describedby="threshold-hint">
<span id="threshold-hint" class="controls-hint">RMS 01 · sections above this value are marked loud</span> <span id="threshold-hint" class="controls-hint">RMS 01 · sections above this value are marked loud</span>
<label for="preroll-input" style="margin-left:16px">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="controls-hint">seconds to rewind before section start</span>
</div>
<div class="filter-bar" role="search" aria-label="Filter recordings">
<label for="filter-name">Search:</label>
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
<label for="filter-from">From:</label>
<input type="date" id="filter-from" aria-label="From date">
<label for="filter-to">To:</label>
<input type="date" id="filter-to" aria-label="To date">
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
</div> </div>
<div class="wrap" id="main"> <div class="wrap" id="main">
<table aria-label="Recordings archive"> <table aria-label="Recordings archive">
@@ -579,8 +735,30 @@ const fmtT = s => {
}; };
const pad = n => String(n).padStart(2,'0'); const pad = n => String(n).padStart(2,'0');
function announce(msg) {
const el = document.getElementById('sr-announce');
if (!el) return;
el.textContent = ''; // clear first so same text re-triggers
setTimeout(() => { el.textContent = msg; }, 50);
}
const getPreroll = () => {
const v = parseFloat(document.getElementById('preroll-input').value);
return isNaN(v) || v < 0 ? 0 : v;
};
function setCutFields(idx, startSec, endSec) {
const startEl = document.getElementById('cut-start-'+idx);
const endEl = document.getElementById('cut-end-'+idx);
if (startEl) startEl.value = fmtT(startSec);
if (endEl && endSec != null) endEl.value = fmtT(endSec);
}
// idx -> filename, for live-status polling // idx -> filename, for live-status polling
const recMap = new Map(); const recMap = new Map();
// idx -> [{start,end}], populated after analysis
const sectionMap = new Map();
let activePlayerIdx = null;
// full file list from server, annotated with stable _idx
let allFiles = [];
function togglePlayer(idx, filename) { function togglePlayer(idx, filename) {
const prow = document.getElementById('prow-'+idx); const prow = document.getElementById('prow-'+idx);
@@ -590,14 +768,16 @@ function togglePlayer(idx, filename) {
if (!open) { if (!open) {
if (!audio.getAttribute('data-src-set')) { if (!audio.getAttribute('data-src-set')) {
audio.preload = 'metadata';
audio.src = '/stream/' + encodeURIComponent(filename); audio.src = '/stream/' + encodeURIComponent(filename);
audio.load();
audio.setAttribute('data-src-set','1'); audio.setAttribute('data-src-set','1');
} }
activePlayerIdx = idx;
prow.hidden = false; prow.hidden = false;
btn.setAttribute('aria-expanded','true'); btn.setAttribute('aria-expanded','true');
btn.textContent = '⏹ Hide'; btn.textContent = '⏹ Hide';
btn.setAttribute('aria-label','Hide player for '+filename); btn.setAttribute('aria-label','Hide player for '+filename);
// Move focus to audio control so keyboard users can operate it immediately
audio.focus(); audio.focus();
} else { } else {
audio.pause(); audio.pause();
@@ -644,7 +824,32 @@ function drawWave(rms, sections, duration, filename) {
return svg; return svg;
} }
async function analyse(filename, cell, btn) { function parseTime(s) {
if (!s || !s.trim()) return null;
const parts = s.trim().split(':').map(v => parseFloat(v));
if (parts.some(isNaN) || parts.length < 1 || 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];
}
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
const pbtn = document.getElementById('pbtn-'+idx);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
activePlayerIdx = idx;
const audio = document.getElementById('aud-'+idx);
const seekTo = Math.max(0, startSec - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; };
if (audio.readyState >= 1) doSeek();
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
setCutFields(idx, startSec, endSec);
if (sectionIdx != null) {
const total = (sectionMap.get(idx) || []).length;
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtT(startSec)} to ${fmtT(endSec)}`);
}
}
async function analyse(idx, filename, cell, btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = ''; btn.textContent = '';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
@@ -664,15 +869,19 @@ async function analyse(filename, cell, btn) {
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className='chips'; chips.className='chips';
chips.setAttribute('role','list'); chips.setAttribute('role','list');
chips.setAttribute('aria-label','Loud sections'); chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
if (d.sections && d.sections.length) { if (d.sections && d.sections.length) {
d.sections.forEach(s => { sectionMap.set(idx, d.sections);
const c = document.createElement('span'); d.sections.forEach((s, si) => {
const c = document.createElement('button');
c.className='chip'; c.setAttribute('role','listitem'); c.className='chip'; c.setAttribute('role','listitem');
c.title = 'Jump to this section (or use J/K keys)';
c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`; c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`;
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
chips.appendChild(c); chips.appendChild(c);
}); });
} else { } else {
sectionMap.delete(idx);
const q = document.createElement('span'); const q = document.createElement('span');
q.className='quiet'; q.setAttribute('role','listitem'); q.className='quiet'; q.setAttribute('role','listitem');
q.textContent='No loud sections found'; q.textContent='No loud sections found';
@@ -686,6 +895,79 @@ async function analyse(filename, cell, btn) {
} }
} }
// J = previous section, K = next section (only when focus is not in an input)
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (activePlayerIdx === null) return;
const sections = sectionMap.get(activePlayerIdx) || [];
if (!sections.length) return;
const audio = document.getElementById('aud-'+activePlayerIdx);
if (!audio) return;
const preroll = getPreroll();
if (e.key === 'j' || e.key === 'J') {
e.preventDefault();
const cur = audio.currentTime;
let targetIdx = -1;
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { targetIdx = i; break; }
}
if (targetIdx >= 0) {
const s = sections[targetIdx];
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
} else {
announce('Beginning of sections');
}
} else if (e.key === 'k' || e.key === 'K') {
e.preventDefault();
const cur = audio.currentTime;
let jumped = false;
for (let i = 0; i < sections.length; i++) {
if (sections[i].start > cur + preroll) {
const s = sections[i];
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`Section ${i + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
jumped = true;
break;
}
}
if (!jumped) announce('End of sections');
}
});
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);
allFiles = allFiles.filter(f => f._idx !== idx);
const visible = document.querySelectorAll('tr.data-row').length;
const total = allFiles.length;
document.getElementById('subtitle').textContent = total === visible
? `${total} recording${total!==1?'s':''} found`
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
if (!visible) 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();
@@ -697,32 +979,21 @@ async function updateStorage() {
} catch(e) {} } catch(e) {}
} }
async function load() { function renderFiles(files) {
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = 'Loading…';
recMap.clear();
let files;
try {
files = await (await fetch('/api/files')).json();
} catch(e) {
document.getElementById('subtitle').textContent = 'Error loading files';
refreshBtn.disabled = false;
return;
}
const tbody = document.getElementById('tbody'); const tbody = document.getElementById('tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
recMap.clear();
sectionMap.clear();
const n = files.length; const total = allFiles.length;
document.getElementById('subtitle').textContent = const visible = files.length;
`${n} recording${n!==1?'s':''} found`; document.getElementById('subtitle').textContent = total === visible
document.getElementById('empty').style.display = n ? 'none' : ''; ? `${total} recording${total!==1?'s':''} found`
updateStorage(); : `${visible} of ${total} recording${total!==1?'s':''} shown`;
if (!n) { refreshBtn.disabled = false; return; } document.getElementById('empty').style.display = visible ? 'none' : '';
files.forEach((f, i) => { files.forEach(f => {
const i = f._idx;
const ext = f.ext; const ext = f.ext;
const canAnalyse = ext === 'wav' || ext === 'flac'; const canAnalyse = ext === 'wav' || ext === 'flac';
const isRec = !!f.recording; const isRec = !!f.recording;
@@ -753,6 +1024,12 @@ 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="cutbtn-${i}" class="cut"
aria-label="Cut ${esc(f.name)}"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
<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);
@@ -762,9 +1039,24 @@ async function load() {
prow.className = 'player-row'; prow.className = 'player-row';
prow.id = 'prow-'+i; prow.id = 'prow-'+i;
prow.hidden = true; prow.hidden = true;
const durLabel = f.duration != null
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
: '';
prow.innerHTML = `<td colspan="6"> prow.innerHTML = `<td colspan="6">
<audio id="aud-${i}" controls preload="none" <audio id="aud-${i}" controls preload="none"
aria-label="Playback: ${esc(f.name)}"></audio> aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
<div class="cut-panel">
<span class="cut-label">✂ Cut:</span>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<label class="cut-field">End
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<button id="cut-dl-${i}" class="cut"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
</div>
</td>`; </td>`;
tbody.appendChild(prow); tbody.appendChild(prow);
@@ -778,7 +1070,7 @@ async function load() {
abtn.disabled = true; abtn.disabled = true;
abtn.title = 'Recording in progress — analyse after recording stops'; abtn.title = 'Recording in progress — analyse after recording stops';
} else { } else {
abtn.addEventListener('click', () => analyse(f.name, cell, abtn)); abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
} }
cell.appendChild(abtn); cell.appendChild(abtn);
} }
@@ -787,10 +1079,72 @@ async function load() {
document.getElementById('pbtn-'+i) document.getElementById('pbtn-'+i)
.addEventListener('click', () => togglePlayer(i, f.name)); .addEventListener('click', () => togglePlayer(i, f.name));
// ---- register for live-status polling ---- // ---- attach cut button handler (opens player row, focuses start field) ----
if (!isRec) {
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
const pbtn = document.getElementById('pbtn-'+i);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
document.getElementById('cut-start-'+i)?.focus();
});
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
const startStr = document.getElementById('cut-start-'+i).value;
const endStr = document.getElementById('cut-end-'+i).value;
const start = parseTime(startStr);
const end = parseTime(endStr);
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/cut?file=' + encodeURIComponent(f.name) +
'&start=' + start + '&end=' + end;
});
}
// ---- attach delete button handler ----
if (!isRec) {
document.getElementById('delbtn-'+i)
.addEventListener('click', () => deleteFile(i, f.name));
}
recMap.set(i, f.name); recMap.set(i, f.name);
}); });
}
function applyFilters() {
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
const fromD = document.getElementById('filter-from').value;
const toD = document.getElementById('filter-to').value;
const filtered = 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;
});
renderFiles(filtered);
}
async function load() {
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = 'Loading…';
let files;
try {
files = await (await fetch('/api/files')).json();
} catch(e) {
document.getElementById('subtitle').textContent = 'Error loading files';
refreshBtn.disabled = false;
return;
}
allFiles = files.map((f, i) => ({...f, _idx: i}));
updateStorage();
applyFilters();
refreshBtn.disabled = false; refreshBtn.disabled = false;
} }
@@ -811,6 +1165,16 @@ async function pollStatus() {
document.getElementById('refresh-btn').addEventListener('click', load); document.getElementById('refresh-btn').addEventListener('click', load);
document.getElementById('filter-name').addEventListener('input', applyFilters);
document.getElementById('filter-from').addEventListener('change', applyFilters);
document.getElementById('filter-to').addEventListener('change', applyFilters);
document.getElementById('filter-clear').addEventListener('click', () => {
document.getElementById('filter-name').value = '';
document.getElementById('filter-from').value = '';
document.getElementById('filter-to').value = '';
applyFilters();
});
// Seed threshold input from server config, then start // Seed threshold input from server config, then start
fetch('/api/config').then(r => r.json()).then(cfg => { fetch('/api/config').then(r => r.json()).then(cfg => {
if (cfg.threshold != null) if (cfg.threshold != null)
@@ -845,7 +1209,7 @@ def main():
recordings_dir = str(rec_dir.resolve()) recordings_dir = str(rec_dir.resolve())
threshold = args.threshold threshold = args.threshold
server = HTTPServer((args.host, args.port), Handler) server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"ISR Web running → http://{args.host}:{args.port}/") print(f"ISR Web running → http://{args.host}:{args.port}/")
print(f"Recordings dir → {rec_dir.resolve()}") print(f"Recordings dir → {rec_dir.resolve()}")