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:
@@ -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">↻ 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 0–1 · 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>"""
|
||||
|
||||
Reference in New Issue
Block a user