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 --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 --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
```
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).
**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:
```bash
# Delete recordings older than 30 days
+2 -1
View File
@@ -16,7 +16,8 @@ services:
build: .
volumes:
- ./recordings:/recordings:ro
- ./analyses:/analyses
ports:
- "8080:8080"
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
channels: int # Max input channels
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_monitor: bool = False # Is a monitor/loopback source
description: str = "" # Extended description
+1 -47
View File
@@ -4,15 +4,13 @@ Tests for isr.py
Run with: pytest tests/
"""
import io
import logging
import struct
import wave
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import List
from unittest.mock import MagicMock, patch, call
from unittest.mock import MagicMock, patch
import pytest
@@ -571,50 +569,6 @@ class TestStreamRecorderRecord:
)
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):
rec = self._recorder()
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
# ---------------------------------------------------------------------------
def _analysis_cache_path(base: Path, audio_path: Path) -> Path:
rel = audio_path.relative_to(base)
return base / 'analyses' / rel.parent / (rel.name + '.analysis.json')
def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path: Path) -> Path:
rel = audio_path.relative_to(recordings_base)
return analyses_base / rel.parent / (rel.name + '.analysis.json')
def prune_orphan_analyses(base: Path):
analyses_dir = base / 'analyses'
if not analyses_dir.exists():
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
if not analyses_base.exists():
return
removed = 0
for cache in analyses_dir.rglob('*.analysis.json'):
rel = cache.relative_to(analyses_dir)
audio_path = base / rel.parent / rel.name[:-len('.analysis.json')]
for cache in analyses_base.rglob('*.analysis.json'):
rel = cache.relative_to(analyses_base)
audio_path = recordings_base / rel.parent / rel.name[:-len('.analysis.json')]
if not audio_path.exists():
try:
cache.unlink()
@@ -283,6 +282,7 @@ def list_files(recordings_dir: str):
class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings'
analyses_dir: str = 'recordings/analyses'
threshold: float = LOUD_THRESHOLD
min_gap: float = MIN_GAP_SECONDS
@@ -358,12 +358,14 @@ class _Handler(BaseHTTPRequestHandler):
except Exception:
pass
base = Path(self.recordings_dir).resolve()
cache_path = _analysis_cache_path(base, path)
recordings_base = Path(self.recordings_dir).resolve()
analyses_base = Path(self.analyses_dir).resolve()
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
try:
cached = json.loads(cache_path.read_text('utf-8'))
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
except Exception:
pass
@@ -385,8 +387,8 @@ class _Handler(BaseHTTPRequestHandler):
tmp = cache_path.with_suffix('.tmp')
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
os.replace(tmp, cache_path)
except Exception:
pass
except Exception as e:
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
@@ -511,7 +513,11 @@ class _Handler(BaseHTTPRequestHandler):
return
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:
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}
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
.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)}
/* player row */
.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 (!audio.getAttribute('data-src-set')) {
audio.preload = 'metadata';
audio.preload = 'auto';
audio.src = '/stream/' + encodeURIComponent(filename);
audio.load();
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);
activePlayerIdx = idx;
const audio = document.getElementById('aud-'+idx);
audio.preload = 'auto';
const seekTo = Math.max(0, startSec - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
if (audio.readyState >= 1) doSeek();
@@ -954,6 +966,12 @@ async function analyse(idx, filename, cell, btn) {
}
const box = document.createElement('div'); box.className='wbox';
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');
chips.className='chips';
@@ -1322,22 +1340,43 @@ async function dayHighlights(dayId, analyzableFiles) {
hlRow.hidden = false;
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;
// 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 minGap = document.getElementById('min-gap-input').value || '2';
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 {
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
+ '&threshold=' + encodeURIComponent(threshold)
+ '&min_gap=' + encodeURIComponent(minGap));
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) {}
}
progFill.style.width = '100%';
progFile.textContent = `Done — ${n} file${n!==1?'s':''} (${nCached} cached, ${nLive} analysed)`;
progTally.textContent = '';
if (!results.length) {
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
@@ -1439,6 +1478,14 @@ async function dayHighlights(dayId, analyzableFiles) {
box.appendChild(labels);
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');
chips.className = 'chips';
chips.setAttribute('role', 'list');
@@ -1458,6 +1505,7 @@ async function dayHighlights(dayId, analyzableFiles) {
});
box.appendChild(chips);
}
}
const summary = document.createElement('div');
summary.className = 'quiet';
@@ -1498,6 +1546,7 @@ function jumpToDaySection(si) {
const audio = document.getElementById('aud-' + fileIdx);
if (!audio) return;
audio.preload = 'auto';
const seekTo = Math.max(0, start - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
if (audio.readyState >= 1) doSeek();
@@ -1595,23 +1644,29 @@ def main():
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
parser.add_argument('--min-gap', type=float, 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()
rec_dir = Path(args.dir)
if not rec_dir.exists():
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
rec_dir = Path(args.dir).resolve()
analyses_dir = Path(args.analyses_dir).resolve() if args.analyses_dir else rec_dir / 'analyses'
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):
recordings_dir = str(rec_dir.resolve())
recordings_dir = str(rec_dir)
analyses_dir = str(analyses_dir)
threshold = args.threshold
min_gap = args.min_gap
server = ThreadingHTTPServer((args.host, args.port), Handler)
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}")
if not NUMPY_AVAILABLE:
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")