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 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">↻ Refresh</button>
|
<button id="refresh-btn" aria-label="Refresh file list">↻ 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 0–1 · 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>"""
|
||||||
|
|||||||
Reference in New Issue
Block a user