Compare commits

...

6 Commits

Author SHA1 Message Date
admin 9ba084107b chore: remove dead test code and fix stale comment
tests/test_isr.py:
- Remove unused imports: io, SimpleNamespace, call
- Remove test_mp3_chunks_written_to_file — it mocked connect_stream but
  never called record(), making all the mock scaffolding dead; the actual
  assertion (bytes written to a file) is already covered by
  TestAudioFileWriter and TestGenerateFilename

isr.py:
- Update AudioDevice.backend comment: only the ALSA backend exists now

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:39:34 +02:00
5 changed files with 111 additions and 98 deletions
+3
View File
@@ -152,6 +152,7 @@ python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold 01 (default 0.05) python web.py --threshold 0.03 # loudness threshold 01 (default 0.05)
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2) python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
``` ```
Shows recordings grouped by day with collapsible sections. Features: Shows recordings grouped by day with collapsible sections. Features:
@@ -211,6 +212,8 @@ docker compose down && docker compose up -d --build
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount). **Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
**Analysis cache in Docker:** The web container mounts `./recordings` read-only, so analysis cache files are written to a separate `./analyses` bind mount (mapped to `/analyses` inside the container). This directory is created automatically by Docker Compose on first run. Cache files are stored as `analyses/<filename>.analysis.json` on the host.
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host: **File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
```bash ```bash
# Delete recordings older than 30 days # Delete recordings older than 30 days
+2 -1
View File
@@ -16,7 +16,8 @@ services:
build: . build: .
volumes: volumes:
- ./recordings:/recordings:ro - ./recordings:/recordings:ro
- ./analyses:/analyses
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped
command: ["python", "web.py", "--dir", "/recordings"] command: ["python", "web.py", "--dir", "/recordings", "--analyses-dir", "/analyses"]
+1 -1
View File
@@ -54,7 +54,7 @@ class AudioDevice:
name: str # Human-readable name name: str # Human-readable name
channels: int # Max input channels channels: int # Max input channels
sample_rate: int # Default sample rate sample_rate: int # Default sample rate
backend: str # Backend name (pulseaudio, pipewire, portaudio) backend: str # Backend name ('alsa')
is_default: bool = False # Is system default is_default: bool = False # Is system default
is_monitor: bool = False # Is a monitor/loopback source is_monitor: bool = False # Is a monitor/loopback source
description: str = "" # Extended description description: str = "" # Extended description
+1 -47
View File
@@ -4,15 +4,13 @@ Tests for isr.py
Run with: pytest tests/ Run with: pytest tests/
""" """
import io
import logging import logging
import struct import struct
import wave import wave
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import List from typing import List
from unittest.mock import MagicMock, patch, call from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -571,50 +569,6 @@ class TestStreamRecorderRecord:
) )
return r return r
def test_mp3_chunks_written_to_file(self, tmp_path):
chunks = [b"A" * 512, b"B" * 512, b"C" * 512]
rec = self._recorder(fmt="mp3")
rec.output_dir = str(tmp_path)
mock_resp = MagicMock()
mock_resp.headers = {"Content-Type": "audio/mpeg"}
mock_resp.iter_content.return_value = iter(chunks)
def _stop_after_connect(*args, **kwargs):
rec.running = True
return mock_resp
with patch.object(rec, "connect_stream", side_effect=[mock_resp, None]):
# connect_stream returns the mocked response on the first call;
# we stop the loop via a side-effectful iter_content
original_iter = mock_resp.iter_content.return_value
def _chunks_then_stop(chunk_size=8192):
for c in chunks:
yield c
rec.running = False # stop the outer while loop
mock_resp.iter_content.side_effect = _chunks_then_stop
mock_resp.headers = {"Content-Type": "audio/mpeg"}
rec.detected_format = "mp3"
rec.running = True
rec.header_capture_complete = True
rec.stream_headers = None
# Open a file manually so we can verify writes
filename = rec.generate_filename("mp3")
rec.current_file = open(filename, "wb")
rec.current_filename = filename
# Simulate one inner loop iteration
for chunk in _chunks_then_stop():
if chunk:
rec.current_file.write(chunk)
rec.close_current_file()
written = Path(filename).read_bytes()
assert written == b"A" * 512 + b"B" * 512 + b"C" * 512
def test_connection_failure_retries(self, tmp_path): def test_connection_failure_retries(self, tmp_path):
rec = self._recorder() rec = self._recorder()
rec.output_dir = str(tmp_path) rec.output_dir = str(tmp_path)
+80 -25
View File
@@ -207,19 +207,18 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
# Analysis cache helpers # Analysis cache helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _analysis_cache_path(base: Path, audio_path: Path) -> Path: def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path: Path) -> Path:
rel = audio_path.relative_to(base) rel = audio_path.relative_to(recordings_base)
return base / 'analyses' / rel.parent / (rel.name + '.analysis.json') return analyses_base / rel.parent / (rel.name + '.analysis.json')
def prune_orphan_analyses(base: Path): def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
analyses_dir = base / 'analyses' if not analyses_base.exists():
if not analyses_dir.exists():
return return
removed = 0 removed = 0
for cache in analyses_dir.rglob('*.analysis.json'): for cache in analyses_base.rglob('*.analysis.json'):
rel = cache.relative_to(analyses_dir) rel = cache.relative_to(analyses_base)
audio_path = base / rel.parent / rel.name[:-len('.analysis.json')] audio_path = recordings_base / rel.parent / rel.name[:-len('.analysis.json')]
if not audio_path.exists(): if not audio_path.exists():
try: try:
cache.unlink() cache.unlink()
@@ -283,6 +282,7 @@ def list_files(recordings_dir: str):
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings' recordings_dir: str = 'recordings'
analyses_dir: str = 'recordings/analyses'
threshold: float = LOUD_THRESHOLD threshold: float = LOUD_THRESHOLD
min_gap: float = MIN_GAP_SECONDS min_gap: float = MIN_GAP_SECONDS
@@ -358,12 +358,14 @@ class _Handler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass pass
base = Path(self.recordings_dir).resolve() recordings_base = Path(self.recordings_dir).resolve()
cache_path = _analysis_cache_path(base, path) analyses_base = Path(self.analyses_dir).resolve()
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
try: try:
cached = json.loads(cache_path.read_text('utf-8')) cached = json.loads(cache_path.read_text('utf-8'))
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap: if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap:
self._send(200, json.dumps(cached['result']).encode('utf-8'), 'application/json') payload = dict(cached['result']); payload['cached'] = True
self._send(200, json.dumps(payload).encode('utf-8'), 'application/json')
return return
except Exception: except Exception:
pass pass
@@ -385,8 +387,8 @@ class _Handler(BaseHTTPRequestHandler):
tmp = cache_path.with_suffix('.tmp') tmp = cache_path.with_suffix('.tmp')
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8') tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
os.replace(tmp, cache_path) os.replace(tmp, cache_path)
except Exception: except Exception as e:
pass print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
self._send(200, json.dumps(result).encode('utf-8'), 'application/json') self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
@@ -511,7 +513,11 @@ class _Handler(BaseHTTPRequestHandler):
return return
try: try:
_analysis_cache_path(Path(self.recordings_dir).resolve(), path).unlink() _analysis_cache_path(
Path(self.analyses_dir).resolve(),
Path(self.recordings_dir).resolve(),
path,
).unlink()
except Exception: except Exception:
pass pass
@@ -703,6 +709,11 @@ button.chip:hover{background:#6c1f08;border-color:#9a3412}
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px} 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}
.prog-bar{height:3px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
.prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 0.15s ease}
.prog-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.prog-tally{font-size:11px;color:var(--muted);margin-top:3px}
.cached-badge{font-size:10px;color:var(--muted);background:var(--surf);border:1px solid var(--brd);border-radius:3px;padding:1px 5px;margin-left:6px;vertical-align:middle;font-style:normal}
.empty{text-align:center;padding:60px;color:var(--muted)} .empty{text-align:center;padding:60px;color:var(--muted)}
/* player row */ /* player row */
.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)}
@@ -855,7 +866,7 @@ function togglePlayer(idx, filename) {
if (!open) { if (!open) {
if (!audio.getAttribute('data-src-set')) { if (!audio.getAttribute('data-src-set')) {
audio.preload = 'metadata'; audio.preload = 'auto';
audio.src = '/stream/' + encodeURIComponent(filename); audio.src = '/stream/' + encodeURIComponent(filename);
audio.load(); audio.load();
audio.setAttribute('data-src-set','1'); audio.setAttribute('data-src-set','1');
@@ -925,6 +936,7 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename); if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
activePlayerIdx = idx; activePlayerIdx = idx;
const audio = document.getElementById('aud-'+idx); const audio = document.getElementById('aud-'+idx);
audio.preload = 'auto';
const seekTo = Math.max(0, startSec - getPreroll()); const seekTo = Math.max(0, startSec - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); }; const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
if (audio.readyState >= 1) doSeek(); if (audio.readyState >= 1) doSeek();
@@ -954,6 +966,12 @@ async function analyse(idx, filename, cell, btn) {
} }
const box = document.createElement('div'); box.className='wbox'; const box = document.createElement('div'); box.className='wbox';
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename)); box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
if (d.cached) {
const badge = document.createElement('span');
badge.className = 'cached-badge'; badge.textContent = 'cached';
badge.title = 'Result loaded from cache — change threshold/gap and re-analyse to recompute';
box.firstChild.after(badge);
}
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className='chips'; chips.className='chips';
@@ -1322,22 +1340,43 @@ async function dayHighlights(dayId, analyzableFiles) {
hlRow.hidden = false; hlRow.hidden = false;
const n = analyzableFiles.length; const n = analyzableFiles.length;
contentEl.innerHTML = `<div class="spin" aria-live="polite" aria-busy="true">Analysing ${n} file${n!==1?'s':''}…</div>`;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
// Build progress UI
const progWrap = document.createElement('div');
progWrap.setAttribute('aria-live', 'polite'); progWrap.setAttribute('aria-busy', 'true');
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%';
progBar.appendChild(progFill);
const progFile = document.createElement('div'); progFile.className = 'prog-file';
const progTally = document.createElement('div'); progTally.className = 'prog-tally';
progWrap.appendChild(progBar); progWrap.appendChild(progFile); progWrap.appendChild(progTally);
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
const threshold = document.getElementById('threshold-input').value || '0.05'; const threshold = document.getElementById('threshold-input').value || '0.05';
const minGap = document.getElementById('min-gap-input').value || '2'; const minGap = document.getElementById('min-gap-input').value || '2';
const results = []; const results = [];
for (const f of analyzableFiles) { let nCached = 0, nLive = 0;
for (let i = 0; i < analyzableFiles.length; i++) {
const f = analyzableFiles[i];
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`;
const tallyParts = [];
if (nCached) tallyParts.push(`${nCached} cached`);
if (nLive) tallyParts.push(`${nLive} analysed`);
progTally.textContent = tallyParts.join(' · ');
try { try {
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name) const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
+ '&threshold=' + encodeURIComponent(threshold) + '&threshold=' + encodeURIComponent(threshold)
+ '&min_gap=' + encodeURIComponent(minGap)); + '&min_gap=' + encodeURIComponent(minGap));
const d = await r.json(); const d = await r.json();
if (!d.error) results.push({ f, data: d }); if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
} catch(e) {} } catch(e) {}
} }
progFill.style.width = '100%';
progFile.textContent = `Done — ${n} file${n!==1?'s':''} (${nCached} cached, ${nLive} analysed)`;
progTally.textContent = '';
if (!results.length) { if (!results.length) {
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>'; contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
@@ -1439,6 +1478,14 @@ async function dayHighlights(dayId, analyzableFiles) {
box.appendChild(labels); box.appendChild(labels);
if (dayActiveSections.length) { if (dayActiveSections.length) {
const MAX_DAY_CHIPS = 50;
if (dayActiveSections.length > MAX_DAY_CHIPS) {
const note = document.createElement('p');
note.className = 'quiet';
note.style.marginTop = '6px';
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`;
box.appendChild(note);
} else {
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className = 'chips'; chips.className = 'chips';
chips.setAttribute('role', 'list'); chips.setAttribute('role', 'list');
@@ -1458,6 +1505,7 @@ async function dayHighlights(dayId, analyzableFiles) {
}); });
box.appendChild(chips); box.appendChild(chips);
} }
}
const summary = document.createElement('div'); const summary = document.createElement('div');
summary.className = 'quiet'; summary.className = 'quiet';
@@ -1498,6 +1546,7 @@ function jumpToDaySection(si) {
const audio = document.getElementById('aud-' + fileIdx); const audio = document.getElementById('aud-' + fileIdx);
if (!audio) return; if (!audio) return;
audio.preload = 'auto';
const seekTo = Math.max(0, start - getPreroll()); const seekTo = Math.max(0, start - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); }; const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
if (audio.readyState >= 1) doSeek(); if (audio.readyState >= 1) doSeek();
@@ -1595,23 +1644,29 @@ def main():
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})') help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS, parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})') help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
parser.add_argument('--analyses-dir', default=None,
help='Directory for analysis cache files (default: <recordings-dir>/analyses)')
args = parser.parse_args() args = parser.parse_args()
rec_dir = Path(args.dir) rec_dir = Path(args.dir).resolve()
if not rec_dir.exists(): analyses_dir = Path(args.analyses_dir).resolve() if args.analyses_dir else rec_dir / 'analyses'
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
prune_orphan_analyses(rec_dir.resolve()) if not rec_dir.exists():
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
prune_orphan_analyses(analyses_dir, rec_dir)
class Handler(_Handler): class Handler(_Handler):
recordings_dir = str(rec_dir.resolve()) recordings_dir = str(rec_dir)
analyses_dir = str(analyses_dir)
threshold = args.threshold threshold = args.threshold
min_gap = args.min_gap min_gap = args.min_gap
server = ThreadingHTTPServer((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}")
print(f"Analyses dir → {analyses_dir}")
print(f"Loud threshold → {args.threshold}") print(f"Loud threshold → {args.threshold}")
if not NUMPY_AVAILABLE: if not NUMPY_AVAILABLE:
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable") print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")