Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba084107b | |||
| 4aea07ae40 | |||
| b6b328dfb8 | |||
| a68af56421 | |||
| 7821f8823d | |||
| 75434ca96d |
@@ -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 0–1 (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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 0–1 (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")
|
||||
|
||||
Reference in New Issue
Block a user