diff --git a/README.md b/README.md index 96d83b0..a582520 100644 --- a/README.md +++ b/README.md @@ -151,16 +151,20 @@ python web.py # serves ./recordings on port 8080 python web.py --dir /path/to/audio # custom recordings directory python web.py --port 8888 # custom port python web.py --threshold 0.03 # loudness threshold 0–1 (default 0.05) +python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2) ``` -Shows a table of all recordings sorted newest-first. 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 highlights** — click **★ Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. - **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. +- **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. - **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/` and removes the row without a full page reload. +- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/` and re-renders the table. - **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` (header is unfinalized until recording stops). - **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms. diff --git a/web.py b/web.py index 73f2bfc..7009170 100644 --- a/web.py +++ b/web.py @@ -114,7 +114,7 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int, def _loud_sections(rms_values: list, window_dur: float, duration: float, - threshold: float) -> list: + threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list: sections = [] start_t = None last_loud_t = None @@ -126,7 +126,7 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float, start_t = t last_loud_t = t else: - if start_t is not None and (t - last_loud_t) > MIN_GAP_SECONDS: + if start_t is not None and (t - last_loud_t) > min_gap: sections.append({'start': round(start_t, 1), 'end': round(last_loud_t + window_dur, 1)}) start_t = None @@ -139,7 +139,8 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float, def _package_result(rms_values: list, framerate: int, n_frames: int, - window_samples: int, threshold: float) -> dict: + window_samples: int, threshold: float, + min_gap: float = MIN_GAP_SECONDS) -> dict: window_dur = window_samples / framerate duration = n_frames / framerate @@ -152,14 +153,15 @@ def _package_result(rms_values: list, framerate: int, n_frames: int, return { 'rms': rms_values, 'rms_display': rms_display, - 'sections': _loud_sections(rms_values, window_dur, duration, threshold), + 'sections': _loud_sections(rms_values, window_dur, duration, threshold, min_gap), 'duration': round(duration, 2), 'window': round(window_dur, 4), } def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES, - threshold: float = LOUD_THRESHOLD) -> dict: + threshold: float = LOUD_THRESHOLD, + min_gap: float = MIN_GAP_SECONDS) -> dict: try: with wave.open(str(path), 'rb') as wf: channels = wf.getnchannels() @@ -171,11 +173,12 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES, except Exception as e: return {'error': str(e)} - return _package_result(rms_values, framerate, n_frames, window_samples, threshold) + return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap) def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES, - threshold: float = LOUD_THRESHOLD) -> dict: + threshold: float = LOUD_THRESHOLD, + min_gap: float = MIN_GAP_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'} @@ -197,7 +200,7 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES, except Exception as e: return {'error': str(e)} - return _package_result(rms_values, framerate, n_frames, window_samples, threshold) + return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap) # --------------------------------------------------------------------------- @@ -254,6 +257,7 @@ def list_files(recordings_dir: str): class _Handler(BaseHTTPRequestHandler): recordings_dir: str = 'recordings' threshold: float = LOUD_THRESHOLD + min_gap: float = MIN_GAP_SECONDS def do_DELETE(self): parsed = urlparse(self.path) @@ -312,6 +316,12 @@ class _Handler(BaseHTTPRequestHandler): except (ValueError, TypeError): threshold = self.threshold + 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 + status_path = Path(self.recordings_dir) / 'status.json' try: with open(status_path) as fh: @@ -323,12 +333,12 @@ class _Handler(BaseHTTPRequestHandler): ext = path.suffix.lower() if ext == '.wav': - result = analyze_wav(path, threshold=threshold) + result = analyze_wav(path, threshold=threshold, min_gap=min_gap) elif ext == '.flac': if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE): self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile') return - result = analyze_flac(path, threshold=threshold) + result = analyze_flac(path, threshold=threshold, min_gap=min_gap) else: self._json_err(400, f'Loudness analysis is not available for {ext} files') return @@ -432,7 +442,7 @@ class _Handler(BaseHTTPRequestHandler): self._send(200, data.encode(), 'application/json') def _api_config(self): - data = json.dumps({'threshold': self.threshold}) + data = json.dumps({'threshold': self.threshold, 'min_gap': self.min_gap}) self._send(200, data.encode(), 'application/json') def _api_delete(self, filename: str): @@ -667,6 +677,21 @@ button.cut:hover:not(:disabled){background:#1e3a8a} .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} +/* day groups */ +tr.day-head td{background:var(--surf);padding:7px 10px;border-bottom:2px solid var(--brd);border-top:2px solid var(--brd)} +.day-toggle{background:none;border:none;color:var(--txt);font-size:13px;font-weight:600; + cursor:pointer;padding:2px 0;display:inline-flex;align-items:center;gap:8px} +.day-toggle:hover{color:var(--accent)} +.day-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px} +.day-meta{color:var(--muted);font-size:12px;font-weight:400} +button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px; + margin-left:14px;vertical-align:middle} +button.day-hl:hover:not(:disabled){background:#0a3d1f} +button.day-hl:disabled{opacity:.5;cursor:default} +tr.day-hl-row td{background:var(--bg);padding:8px 12px 12px} +svg.day-timeline{display:block;width:100%;height:22px} +.day-tl-labels{display:flex;justify-content:space-between;font-size:10px; + color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px} @@ -687,6 +712,10 @@ button.cut:hover:not(:disabled){background:#1e3a8a} seconds to rewind before section start + + + seconds — merge loud sections closer than this