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 math
import os import os
import re import re
import shutil
import struct import struct
import wave import wave
from datetime import datetime from datetime import datetime
@@ -68,6 +69,21 @@ def _get_wav_info(path: Path):
return None, None, None 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, def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int): window_samples: int):
"""Yield rms_0_to_1 for every window in the open wave file.""" """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() stat = path.stat()
rel = str(path.relative_to(base)).replace('\\', '/') rel = str(path.relative_to(base)).replace('\\', '/')
duration = None duration = _get_audio_duration(path)
if path.suffix.lower() == '.wav':
duration, _, _ = _get_wav_info(path)
files.append({ files.append({
'name': rel, 'name': rel,
@@ -248,6 +262,10 @@ class _Handler(BaseHTTPRequestHandler):
self._api_analyze(qs) self._api_analyze(qs)
elif p == '/api/status': elif p == '/api/status':
self._api_status() self._api_status()
elif p == '/api/storage':
self._api_storage()
elif p == '/api/config':
self._api_config()
elif p.startswith('/download/'): elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):])) self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'): elif p.startswith('/stream/'):
@@ -272,6 +290,12 @@ class _Handler(BaseHTTPRequestHandler):
if path is None: if path is None:
return 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' status_path = Path(self.recordings_dir) / 'status.json'
try: try:
with open(status_path) as fh: with open(status_path) as fh:
@@ -283,12 +307,12 @@ class _Handler(BaseHTTPRequestHandler):
ext = path.suffix.lower() ext = path.suffix.lower()
if ext == '.wav': if ext == '.wav':
result = analyze_wav(path, threshold=self.threshold) result = analyze_wav(path, threshold=threshold)
elif ext == '.flac': elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE): if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile') self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
return return
result = analyze_flac(path, threshold=self.threshold) result = analyze_flac(path, threshold=threshold)
else: else:
self._json_err(400, f'Loudness analysis is not available for {ext} files') self._json_err(400, f'Loudness analysis is not available for {ext} files')
return return
@@ -374,6 +398,27 @@ class _Handler(BaseHTTPRequestHandler):
break break
self.wfile.write(chunk) 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): def _safe_path(self, filename: str):
base = Path(self.recordings_dir).resolve() base = Path(self.recordings_dir).resolve()
try: try:
@@ -425,10 +470,18 @@ body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
/* sr-only */ /* sr-only */
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px; .sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
header{padding:20px 28px;border-bottom:1px solid var(--brd); header{padding:16px 28px;border-bottom:1px solid var(--brd);
display:flex;align-items:baseline;gap:12px;flex-wrap:wrap} display:flex;align-items:center;gap:12px;flex-wrap:wrap}
header h1{font-size:18px;font-weight:600} 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} .wrap{padding:20px 28px}
table{width:100%;border-collapse:collapse} table{width:100%;border-collapse:collapse}
th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px; 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> <header>
<h1>ISR Archive</h1> <h1>ISR Archive</h1>
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span> <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> <button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
</header> </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"> <div class="wrap" id="main">
<table aria-label="Recordings archive"> <table aria-label="Recordings archive">
<thead> <thead>
@@ -588,8 +648,10 @@ async function analyse(filename, cell, btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = ''; btn.textContent = '';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const threshold = document.getElementById('threshold-input').value || '0.05';
try { 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(); const d = await r.json();
if (d.error) { if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`; 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() { async function load() {
const refreshBtn = document.getElementById('refresh-btn'); const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true; refreshBtn.disabled = true;
@@ -646,6 +719,7 @@ async function load() {
document.getElementById('subtitle').textContent = document.getElementById('subtitle').textContent =
`${n} recording${n!==1?'s':''} found`; `${n} recording${n!==1?'s':''} found`;
document.getElementById('empty').style.display = n ? 'none' : ''; document.getElementById('empty').style.display = n ? 'none' : '';
updateStorage();
if (!n) { refreshBtn.disabled = false; return; } if (!n) { refreshBtn.disabled = false; return; }
files.forEach((f, i) => { files.forEach((f, i) => {
@@ -736,7 +810,12 @@ async function pollStatus() {
} }
document.getElementById('refresh-btn').addEventListener('click', load); 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> </script>
</body> </body>
</html>""" </html>"""