diff --git a/README.md b/README.md index c862e6c..d22d7e0 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,12 @@ strftime codes are substituted at split time. The file extension is added automa ## Web UI (`web.py`) ```bash -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) +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) +python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: /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/.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 diff --git a/docker-compose.yml b/docker-compose.yml index d043afb..10b44f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] diff --git a/web.py b/web.py index 208a7f0..1b3cf1c 100644 --- a/web.py +++ b/web.py @@ -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,8 +358,9 @@ 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: @@ -386,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') @@ -512,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 @@ -1637,25 +1642,31 @@ def main(): help='Bind address (default: 0.0.0.0)') parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD, help=f'RMS loudness threshold 0–1 (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})') + parser.add_argument('--analyses-dir', default=None, + help='Directory for analysis cache files (default: /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")