8254ccde86
- 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)
519 lines
18 KiB
Python
519 lines
18 KiB
Python
#!/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 0–1 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)}">⇓ 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 0–1 (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()
|