From 8121564e8c101ca1ae8c97113c6f17408eb965a6 Mon Sep 17 00:00:00 2001 From: Jonathan Schuster Date: Sun, 26 Apr 2026 17:04:58 +0200 Subject: [PATCH] feat: add storage info, UI threshold control, and FLAC/OGG duration - Show total audio storage used and disk free/total in the page header - Add per-page threshold input (seeded from server --threshold) so the loudness threshold can be adjusted without restarting the server; each Analyse request sends the current UI value to the backend - Fix empty Duration column: FLAC, OGG, and Opus files now report duration via soundfile header metadata (no full decode required) - New /api/storage and /api/config endpoints support the above features --- web.py | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/web.py b/web.py index a8d2651..4491d5f 100644 --- a/web.py +++ b/web.py @@ -17,6 +17,7 @@ import json import math import os import re +import shutil import struct import wave from datetime import datetime @@ -68,6 +69,21 @@ def _get_wav_info(path: Path): return None, None, None +def _get_audio_duration(path: Path): + """Return duration in seconds for any supported audio file, or None.""" + ext = path.suffix.lower() + if ext == '.wav': + dur, _, _ = _get_wav_info(path) + return dur + if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'): + try: + with sf.SoundFile(path) as f: + return round(len(f) / f.samplerate, 2) + except Exception: + return None + return None + + def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int, window_samples: int): """Yield rms_0_to_1 for every window in the open wave file.""" @@ -209,9 +225,7 @@ def list_files(recordings_dir: str): stat = path.stat() rel = str(path.relative_to(base)).replace('\\', '/') - duration = None - if path.suffix.lower() == '.wav': - duration, _, _ = _get_wav_info(path) + duration = _get_audio_duration(path) files.append({ 'name': rel, @@ -248,6 +262,10 @@ class _Handler(BaseHTTPRequestHandler): self._api_analyze(qs) elif p == '/api/status': self._api_status() + elif p == '/api/storage': + self._api_storage() + elif p == '/api/config': + self._api_config() elif p.startswith('/download/'): self._download(unquote(p[len('/download/'):])) elif p.startswith('/stream/'): @@ -272,6 +290,12 @@ class _Handler(BaseHTTPRequestHandler): if path is None: return + try: + threshold = float(qs.get('threshold', [self.threshold])[0]) + threshold = max(0.0, min(1.0, threshold)) + except (ValueError, TypeError): + threshold = self.threshold + status_path = Path(self.recordings_dir) / 'status.json' try: with open(status_path) as fh: @@ -283,12 +307,12 @@ class _Handler(BaseHTTPRequestHandler): ext = path.suffix.lower() if ext == '.wav': - result = analyze_wav(path, threshold=self.threshold) + result = analyze_wav(path, threshold=threshold) 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=self.threshold) + result = analyze_flac(path, threshold=threshold) else: self._json_err(400, f'Loudness analysis is not available for {ext} files') return @@ -374,6 +398,27 @@ class _Handler(BaseHTTPRequestHandler): break self.wfile.write(chunk) + def _api_storage(self): + base = Path(self.recordings_dir) + used = 0 + if base.exists(): + used = sum( + p.stat().st_size + for p in base.rglob('*') + if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS + ) + try: + du = shutil.disk_usage(str(base) if base.exists() else '.') + disk_free, disk_total = du.free, du.total + except Exception: + disk_free = disk_total = None + data = json.dumps({'used': used, 'disk_free': disk_free, 'disk_total': disk_total}) + self._send(200, data.encode(), 'application/json') + + def _api_config(self): + data = json.dumps({'threshold': self.threshold}) + self._send(200, data.encode(), 'application/json') + def _safe_path(self, filename: str): base = Path(self.recordings_dir).resolve() try: @@ -425,10 +470,18 @@ body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif} /* sr-only */ .sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px; overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} -header{padding:20px 28px;border-bottom:1px solid var(--brd); - display:flex;align-items:baseline;gap:12px;flex-wrap:wrap} +header{padding:16px 28px;border-bottom:1px solid var(--brd); + display:flex;align-items:center;gap:12px;flex-wrap:wrap} header h1{font-size:18px;font-weight:600} -#subtitle{color:var(--muted);font-size:13px;margin-right:auto} +#subtitle{color:var(--muted);font-size:13px} +#storage-info{color:var(--muted);font-size:12px;margin-right:auto} +.controls-bar{display:flex;align-items:center;gap:10px;padding:10px 28px; + border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap} +.controls-bar label{font-size:13px;color:var(--muted);white-space:nowrap} +.controls-bar input[type=number]{width:70px;background:var(--bg);border:1px solid var(--brd); + color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px} +.controls-bar input[type=number]:focus{outline:2px solid var(--accent);outline-offset:1px} +.controls-hint{font-size:11px;color:var(--muted)} .wrap{padding:20px 28px} table{width:100%;border-collapse:collapse} th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px; @@ -479,8 +532,15 @@ audio{width:100%;height:36px;border-radius:4px;display:block;

ISR Archive

Loading… +
+
+ + + RMS 0–1 · sections above this value are marked loud +
@@ -588,8 +648,10 @@ async function analyse(filename, cell, btn) { btn.disabled = true; btn.textContent = '…'; cell.innerHTML = '
Analysing…
'; + const threshold = document.getElementById('threshold-input').value || '0.05'; try { - const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)); + const r = await fetch('/api/analyze?file='+encodeURIComponent(filename) + +'&threshold='+encodeURIComponent(threshold)); const d = await r.json(); if (d.error) { cell.innerHTML = ``; @@ -624,6 +686,17 @@ async function analyse(filename, cell, btn) { } } +async function updateStorage() { + try { + const s = await (await fetch('/api/storage')).json(); + const el = document.getElementById('storage-info'); + let txt = fmtSize(s.used) + ' used'; + if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free'; + if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total); + el.textContent = txt; + } catch(e) {} +} + async function load() { const refreshBtn = document.getElementById('refresh-btn'); refreshBtn.disabled = true; @@ -646,6 +719,7 @@ async function load() { document.getElementById('subtitle').textContent = `${n} recording${n!==1?'s':''} found`; document.getElementById('empty').style.display = n ? 'none' : ''; + updateStorage(); if (!n) { refreshBtn.disabled = false; return; } files.forEach((f, i) => { @@ -736,7 +810,12 @@ async function pollStatus() { } document.getElementById('refresh-btn').addEventListener('click', load); -load().then(() => setInterval(pollStatus, 5000)); + +// Seed threshold input from server config, then start +fetch('/api/config').then(r => r.json()).then(cfg => { + if (cfg.threshold != null) + document.getElementById('threshold-input').value = cfg.threshold; +}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000))); """