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
This commit is contained in:
2026-04-26 17:04:58 +02:00
parent d7524afeff
commit 8121564e8c
+89 -10
View File
@@ -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;
<header>
<h1>ISR Archive</h1>
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
<span id="storage-info" aria-live="polite"></span>
<button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
</header>
<div class="controls-bar">
<label for="threshold-input">Analysis threshold:</label>
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
aria-describedby="threshold-hint">
<span id="threshold-hint" class="controls-hint">RMS 01 · sections above this value are marked loud</span>
</div>
<div class="wrap" id="main">
<table aria-label="Recordings archive">
<thead>
@@ -588,8 +648,10 @@ async function analyse(filename, cell, btn) {
btn.disabled = true;
btn.textContent = '';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
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 = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
@@ -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)));
</script>
</body>
</html>"""