Files
ISR/web.py
T
admin 8254ccde86 Add Docker support, fix stale docs, translate UI to English
- Dockerfile + docker-compose.yml: two services (recorder + web) sharing
  ./recordings bind mount; recorder maps /dev/snd for ALSA soundcard access
- requirements.txt: requests, numpy, soundfile
- .dockerignore, updated .gitignore (add __pycache__, .pytest_cache)
- isr.py: add SIGTERM handler for clean Docker shutdown; fix stale error
  message that referenced removed PulseAudio/PipeWire/PortAudio backends
- web.py: translate all German UI strings to English
- config.example.ini: remove PipeWire/PulseAudio/PortAudio backend refs,
  simplify soundcard tips to ALSA only
- README.md: full rewrite as user guide (quick start, config reference,
  Docker notes, how it works)
- CLAUDE.md: update architecture section to reflect ALSA-only backend
- Delete changelog.txt and guide.md (internal session notes)
2026-04-26 10:56:55 +02:00

519 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
ISR Web — Browse and download recorded audio files.
Shows a chronological table of all recordings, allows download,
and analyses WAV files for loud sections using RMS.
Usage:
python web.py # serves recordings/ on port 8080
python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05)
"""
import argparse
import json
import math
import os
import struct
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
try:
import numpy as np
NUMPY_AVAILABLE = True
except ImportError:
NUMPY_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
LOUD_THRESHOLD = 0.05 # RMS 01 scale; sections above this are "interesting"
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
# ---------------------------------------------------------------------------
# Audio helpers
# ---------------------------------------------------------------------------
def _get_wav_info(path: Path):
"""Return (duration_seconds, sample_rate, channels) or (None, None, None)."""
try:
with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate(), wf.getframerate(), wf.getnchannels()
except Exception:
return None, None, None
def _compute_rms_windows(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int):
"""Yield (time_seconds, rms_0_to_1) for every window in the open wave file."""
frame_pos = 0
while True:
raw = wf.readframes(window_samples)
if not raw:
break
n_samp = len(raw) // (sampwidth * channels)
if n_samp == 0:
break
if NUMPY_AVAILABLE:
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
if channels > 1:
arr = arr.reshape(-1, channels).mean(axis=1)
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
else:
fmt = f'<{n_samp * channels}h'
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield frame_pos / framerate, round(rms, 5)
frame_pos += window_samples
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD):
"""
Analyse a WAV file.
Returns a dict with:
rms — full list of RMS values (one per window)
rms_display — downsampled to ≤800 points for the sparkline
sections — list of {start, end} dicts for loud passages
duration — total seconds
window — window duration in seconds
"""
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
rms_values = [rms for _, rms in
_compute_rms_windows(wf, channels, sampwidth, framerate, window_samples)]
except Exception as e:
return {'error': str(e)}
window_dur = window_samples / framerate
duration = n_frames / framerate
# Downsample for the sparkline (max 800 bars)
if len(rms_values) > 800:
step = len(rms_values) / 800
rms_display = [rms_values[int(i * step)] for i in range(800)]
else:
rms_display = rms_values
# Find loud sections
sections: list = []
start_t = None
last_loud_t = None
for i, rms in enumerate(rms_values):
t = i * window_dur
if rms >= threshold:
if start_t is None:
start_t = t
last_loud_t = t
else:
if start_t is not None:
gap = t - last_loud_t
if gap > MIN_GAP_SECONDS:
sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1)})
start_t = None
last_loud_t = None
if start_t is not None:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)})
return {
'rms': rms_values,
'rms_display': rms_display,
'sections': sections,
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
# ---------------------------------------------------------------------------
# File listing
# ---------------------------------------------------------------------------
def list_files(recordings_dir: str):
"""Return list of audio file metadata dicts, sorted newest first."""
base = Path(recordings_dir)
if not base.exists():
return []
files = []
for path in base.rglob('*'):
if path.suffix.lower() not in AUDIO_EXTENSIONS:
continue
stat = path.stat()
rel = path.relative_to(base)
if path.suffix.lower() == '.wav':
duration, _, _ = _get_wav_info(path)
else:
duration = None
files.append({
'name': str(rel).replace('\\', '/'),
'size': stat.st_size,
'mtime': stat.st_mtime,
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'ext': path.suffix.lower().lstrip('.'),
})
files.sort(key=lambda f: f['mtime'], reverse=True)
return files
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings'
threshold: float = LOUD_THRESHOLD
# ---- routing -----------------------------------------------------------
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
p = parsed.path
if p == '/':
self._html()
elif p == '/api/files':
self._api_files()
elif p == '/api/analyze':
self._api_analyze(qs)
elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):]))
else:
self._send(404, b'Not found', 'text/plain')
# ---- endpoints ---------------------------------------------------------
def _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
def _api_files(self):
data = json.dumps(list_files(self.recordings_dir)).encode('utf-8')
self._send(200, data, 'application/json')
def _api_analyze(self, qs):
filename = qs.get('file', [None])[0]
if not filename:
self._json_err(400, 'missing file parameter')
return
path = self._safe_path(filename)
if path is None:
return
if path.suffix.lower() != '.wav':
self._json_err(400, 'loudness analysis is only available for WAV files')
return
result = analyze_wav(path, threshold=self.threshold)
data = json.dumps(result).encode('utf-8')
self._send(200, data, 'application/json')
def _download(self, filename: str):
path = self._safe_path(filename)
if path is None:
return
size = path.stat().st_size
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', f'attachment; filename="{path.name}"')
self.send_header('Content-Length', str(size))
self.end_headers()
with open(path, 'rb') as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
# ---- helpers -----------------------------------------------------------
def _safe_path(self, filename: str):
"""Resolve filename within recordings_dir; return None and send error if invalid."""
base = Path(self.recordings_dir).resolve()
try:
path = (base / filename).resolve()
path.relative_to(base) # raises ValueError if outside base
except (ValueError, Exception):
self._send(403, b'Forbidden', 'text/plain')
return None
if not path.exists():
self._send(404, b'Not found', 'text/plain')
return None
return path
def _send(self, code: int, body: bytes, content_type: str):
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json_err(self, code: int, msg: str):
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
def log_message(self, fmt, *args):
pass # suppress default access log noise
# ---------------------------------------------------------------------------
# Embedded HTML/CSS/JS
# ---------------------------------------------------------------------------
_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ISR Archive</title>
<style>
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
header{padding:20px 28px;border-bottom:1px solid var(--brd);display:flex;align-items:baseline;gap:12px}
header h1{font-size:18px;font-weight:600}
#subtitle{color:var(--muted);font-size:13px}
.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;
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);white-space:nowrap}
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surf)}
.fn{font-family:ui-monospace,monospace;font-size:12px;color:var(--txt)}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:700;text-transform:uppercase;margin-right:5px;border:1px solid}
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
.muted{color:var(--muted)}
btn,button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
color:var(--txt);padding:4px 11px;border-radius:5px;font-size:12px;white-space:nowrap}
button:hover{background:var(--brd)}
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
a.dl:hover{text-decoration:underline}
/* waveform row */
.wrow td{padding:0 10px 14px;background:var(--bg)}
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty{text-align:center;padding:60px;color:var(--muted)}
</style>
</head>
<body>
<header>
<h1>ISR Archive</h1>
<span id="subtitle">Loading…</span>
</header>
<div class="wrap">
<table>
<thead>
<tr>
<th>File</th>
<th>Date</th>
<th>Duration</th>
<th>Size</th>
<th>Waveform / Loud sections</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" style="display:none">No recordings found.</div>
</div>
<script>
const fmtDur = s => {
if (s == null) return '';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
return h ? `${h}:${p(m)}:${p(sec)}` : `${m}:${p(sec)}`;
};
const fmtSize = b => {
if (b < 1024) return b+' B';
if (b < 1<<20) return (b/1024).toFixed(0)+' KB';
if (b < 1<<30) return (b/(1<<20)).toFixed(1)+' MB';
return (b/(1<<30)).toFixed(2)+' GB';
};
const fmtT = s => {
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`;
};
const p = n => String(n).padStart(2,'0');
function drawWave(rms, sections, duration) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('class','wave');
svg.setAttribute('viewBox', `0 0 ${rms.length} 1`);
svg.setAttribute('preserveAspectRatio','none');
// highlight loud sections
if (duration > 0) {
sections.forEach(s => {
const r = document.createElementNS(ns, 'rect');
r.setAttribute('x', (s.start/duration)*rms.length);
r.setAttribute('y', 0);
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
r.setAttribute('height', 1);
r.setAttribute('fill','rgba(249,115,22,0.22)');
svg.appendChild(r);
});
}
const maxV = Math.max(...rms, 0.001);
rms.forEach((v,i) => {
const h = v/maxV;
const r = document.createElementNS(ns,'rect');
r.setAttribute('x', i); r.setAttribute('y', 1-h);
r.setAttribute('width',1); r.setAttribute('height',h);
r.setAttribute('fill','#4f9cf9');
svg.appendChild(r);
});
return svg;
}
async function analyse(filename, cell, btn) {
btn.disabled = true;
btn.textContent = '';
cell.innerHTML = '<div class="spin">Analysing…</div>';
try {
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename));
const d = await r.json();
if (d.error) { cell.innerHTML=`<div class="spin">Error: ${d.error}</div>`; return; }
const box = document.createElement('div'); box.className='wbox';
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0));
const chips = document.createElement('div'); chips.className='chips';
if (d.sections && d.sections.length) {
d.sections.forEach(s => {
const c=document.createElement('span'); c.className='chip';
c.textContent=`${fmtT(s.start)} ${fmtT(s.end)}`;
chips.appendChild(c);
});
} else {
chips.innerHTML='<span class="quiet">No loud sections</span>';
}
box.appendChild(chips);
cell.innerHTML=''; cell.appendChild(box);
} catch(e) {
cell.innerHTML=`<div class="spin">Error: ${e.message}</div>`;
}
}
async function load() {
const files = await (await fetch('/api/files')).json();
const tbody = document.getElementById('tbody');
document.getElementById('subtitle').textContent =
`${files.length} recording${files.length!==1?'s':''} found`;
if (!files.length) { document.getElementById('empty').style.display=''; return; }
files.forEach(f => {
const ext = f.ext;
const tr = document.createElement('tr');
const waveCell = ext==='wav'
? `<td class="wave-cell"></td>`
: `<td><span class="muted" style="font-size:12px">WAV files only</span></td>`;
tr.innerHTML = `
<td><span class="badge badge-${ext}">${ext}</span><span class="fn">${f.name}</span></td>
<td class="muted" style="white-space:nowrap">${f.date}</td>
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
${waveCell}
<td><a class="dl" href="/download/${encodeURIComponent(f.name)}">&#8659; Download</a></td>`;
tbody.appendChild(tr);
if (ext === 'wav') {
const cell = tr.querySelector('.wave-cell');
const btn = document.createElement('button');
btn.textContent = 'Analyse';
btn.addEventListener('click', () => analyse(f.name, cell, btn));
cell.appendChild(btn);
}
});
}
load();
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description='ISR Web — audio archive browser')
parser.add_argument('--dir', default='recordings',
help='Recordings directory (default: recordings)')
parser.add_argument('--port', type=int, default=8080,
help='HTTP port (default: 8080)')
parser.add_argument('--host', default='0.0.0.0',
help='Bind address (default: 0.0.0.0)')
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
args = parser.parse_args()
rec_dir = Path(args.dir)
if not rec_dir.exists():
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
class Handler(_Handler):
recordings_dir = str(rec_dir.resolve())
threshold = args.threshold
server = HTTPServer((args.host, args.port), Handler)
print(f"ISR Web running → http://{args.host}:{args.port}/")
print(f"Recordings dir → {rec_dir.resolve()}")
print(f"Loud threshold → {args.threshold}")
if not NUMPY_AVAILABLE:
print("Note: numpy not installed — RMS analysis uses pure Python (slower for large files)")
print("Stop with Ctrl+C\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("Stopped.")
if __name__ == '__main__':
main()