diff --git a/isr.py b/isr.py
index 06fb0e4..4fd5e86 100644
--- a/isr.py
+++ b/isr.py
@@ -5,6 +5,7 @@ Records from multiple sources: Icecast streams and soundcards.
Supports time-based file splitting and concurrent recording.
"""
+import json
import os
import sys
import time
@@ -787,6 +788,8 @@ class RecorderManager:
self.threads: List[threading.Thread] = []
self.logger = None
self.audio_system = None
+ self.output_dir = 'recordings'
+ self._status_running = False
self._load_config()
@@ -812,6 +815,8 @@ class RecorderManager:
'log_file': config.get('general', 'log_file', fallback='recorder.log'),
}
+ self.output_dir = general['output_directory']
+
# Setup logging
self._setup_logging(general['log_level'], general['log_file'])
@@ -877,6 +882,32 @@ class RecorderManager:
)
self.logger = logging.getLogger(__name__)
+ def _write_status(self):
+ """Write active filenames to status.json for the web UI to read."""
+ active = []
+ for r in self.recorders:
+ fn = r.current_filename
+ if fn:
+ try:
+ rel = os.path.relpath(fn, self.output_dir).replace('\\', '/')
+ active.append(rel)
+ except ValueError:
+ pass
+ try:
+ out = Path(self.output_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ tmp = str(out / 'status.json.tmp')
+ with open(tmp, 'w') as fh:
+ json.dump({'active': active}, fh)
+ os.replace(tmp, str(out / 'status.json'))
+ except Exception:
+ pass
+
+ def _status_writer_loop(self):
+ while self._status_running:
+ self._write_status()
+ time.sleep(2)
+
def start(self):
"""Start all recorders in separate threads."""
if not self.recorders:
@@ -891,12 +922,15 @@ class RecorderManager:
self.threads.append(thread)
thread.start()
+ self._status_running = True
+ st = threading.Thread(target=self._status_writer_loop, name='StatusWriter', daemon=True)
+ st.start()
+
self.logger.info("All recorders started")
# Wait for interrupt
try:
while True:
- # Check if all threads are still alive
alive = [t for t in self.threads if t.is_alive()]
if not alive:
self.logger.info("All recorders have stopped")
@@ -908,13 +942,21 @@ class RecorderManager:
def stop(self):
"""Stop all recorders gracefully."""
+ self._status_running = False
for recorder in self.recorders:
recorder.stop()
- # Wait for threads to finish
for thread in self.threads:
thread.join(timeout=5)
+ # Clear status file so the web UI shows no active recordings
+ try:
+ status_path = Path(self.output_dir) / 'status.json'
+ if status_path.exists():
+ status_path.unlink()
+ except Exception:
+ pass
+
self.logger.info("All recorders stopped")
diff --git a/web.py b/web.py
index 373a51b..5822ebc 100644
--- a/web.py
+++ b/web.py
@@ -2,8 +2,8 @@
"""
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.
+Shows a chronological table of all recordings, allows inline playback,
+download, and analyses WAV/FLAC files for loud sections using RMS.
Usage:
python web.py # serves recordings/ on port 8080
@@ -16,6 +16,7 @@ import argparse
import json
import math
import os
+import re
import struct
import wave
from datetime import datetime
@@ -29,6 +30,12 @@ try:
except ImportError:
NUMPY_AVAILABLE = False
+try:
+ import soundfile as sf
+ SOUNDFILE_AVAILABLE = True
+except ImportError:
+ SOUNDFILE_AVAILABLE = False
+
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -38,9 +45,18 @@ 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
+MIME_TYPES = {
+ '.wav': 'audio/wav',
+ '.mp3': 'audio/mpeg',
+ '.ogg': 'audio/ogg',
+ '.flac': 'audio/flac',
+ '.aac': 'audio/aac',
+ '.opus': 'audio/ogg',
+}
+
# ---------------------------------------------------------------------------
-# Audio helpers
+# Audio analysis helpers
# ---------------------------------------------------------------------------
def _get_wav_info(path: Path):
@@ -52,9 +68,9 @@ def _get_wav_info(path: Path):
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."""
+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."""
frame_pos = 0
while True:
raw = wf.readframes(window_samples)
@@ -75,46 +91,13 @@ def _compute_rms_windows(wf, channels: int, sampwidth: int, framerate: int,
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)
+ yield 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 = []
+def _loud_sections(rms_values: list, window_dur: float, duration: float,
+ threshold: float) -> list:
+ sections = []
start_t = None
last_loud_t = None
@@ -125,26 +108,80 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
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 and (t - last_loud_t) > 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 sections
+
+
+def _package_result(rms_values: list, framerate: int, n_frames: int,
+ window_samples: int, threshold: float) -> dict:
+ window_dur = window_samples / framerate
+ duration = n_frames / framerate
+
+ 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
+
return {
'rms': rms_values,
'rms_display': rms_display,
- 'sections': sections,
+ 'sections': _loud_sections(rms_values, window_dur, duration, threshold),
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
+def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
+ threshold: float = LOUD_THRESHOLD) -> dict:
+ try:
+ with wave.open(str(path), 'rb') as wf:
+ channels = wf.getnchannels()
+ sampwidth = wf.getsampwidth()
+ framerate = wf.getframerate()
+ n_frames = wf.getnframes()
+ rms_values = list(_compute_rms_windows_wav(
+ wf, channels, sampwidth, framerate, window_samples))
+ except Exception as e:
+ return {'error': str(e)}
+
+ return _package_result(rms_values, framerate, n_frames, window_samples, threshold)
+
+
+def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
+ threshold: float = LOUD_THRESHOLD) -> dict:
+ """Analyse a FLAC file for loudness. Requires numpy and soundfile."""
+ if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
+ return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
+
+ try:
+ with sf.SoundFile(path) as f:
+ framerate = f.samplerate
+ channels = f.channels
+ n_frames = len(f)
+
+ rms_values = []
+ while True:
+ frames = f.read(window_samples, dtype='float32', always_2d=True)
+ if len(frames) == 0:
+ break
+ mono = frames.mean(axis=1) if channels > 1 else frames[:, 0]
+ rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
+ rms_values.append(round(rms, 5))
+ except Exception as e:
+ return {'error': str(e)}
+
+ return _package_result(rms_values, framerate, n_frames, window_samples, threshold)
+
+
# ---------------------------------------------------------------------------
# File listing
# ---------------------------------------------------------------------------
@@ -155,25 +192,35 @@ def list_files(recordings_dir: str):
if not base.exists():
return []
+ # Load active recordings written by isr.py
+ active_files: set = set()
+ status_path = base / 'status.json'
+ if status_path.exists():
+ try:
+ with open(status_path) as fh:
+ active_files = set(json.load(fh).get('active', []))
+ except Exception:
+ pass
+
files = []
for path in base.rglob('*'):
if path.suffix.lower() not in AUDIO_EXTENSIONS:
continue
stat = path.stat()
- rel = path.relative_to(base)
+ rel = str(path.relative_to(base)).replace('\\', '/')
+ duration = None
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('.'),
+ 'name': rel,
+ '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('.'),
+ 'recording': rel in active_files,
})
files.sort(key=lambda f: f['mtime'], reverse=True)
@@ -188,8 +235,6 @@ 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)
@@ -201,13 +246,15 @@ class _Handler(BaseHTTPRequestHandler):
self._api_files()
elif p == '/api/analyze':
self._api_analyze(qs)
+ elif p == '/api/status':
+ self._api_status()
elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):]))
+ elif p.startswith('/stream/'):
+ self._stream(unquote(p[len('/stream/'):]))
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')
@@ -225,13 +272,29 @@ class _Handler(BaseHTTPRequestHandler):
if path is None:
return
- if path.suffix.lower() != '.wav':
- self._json_err(400, 'loudness analysis is only available for WAV files')
+ ext = path.suffix.lower()
+ if ext == '.wav':
+ result = analyze_wav(path, threshold=self.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)
+ else:
+ self._json_err(400, f'Loudness analysis is not available for {ext} files')
return
- result = analyze_wav(path, threshold=self.threshold)
- data = json.dumps(result).encode('utf-8')
- self._send(200, data, 'application/json')
+ self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
+
+ def _api_status(self):
+ status_path = Path(self.recordings_dir) / 'status.json'
+ if status_path.exists():
+ try:
+ self._send(200, status_path.read_bytes(), 'application/json')
+ return
+ except Exception:
+ pass
+ self._send(200, b'{"active":[]}', 'application/json')
def _download(self, filename: str):
path = self._safe_path(filename)
@@ -252,14 +315,61 @@ class _Handler(BaseHTTPRequestHandler):
break
self.wfile.write(chunk)
- # ---- helpers -----------------------------------------------------------
+ def _stream(self, filename: str):
+ """Serve audio for inline playback with HTTP Range support."""
+ path = self._safe_path(filename)
+ if path is None:
+ return
+
+ content_type = MIME_TYPES.get(path.suffix.lower(), 'application/octet-stream')
+ size = path.stat().st_size
+ range_header = self.headers.get('Range', '')
+ m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
+
+ if m:
+ start = int(m.group(1))
+ end = int(m.group(2)) if m.group(2) else size - 1
+ end = min(end, size - 1)
+ if start > end or start >= size:
+ self._send(416, b'Range Not Satisfiable', 'text/plain')
+ return
+ length = end - start + 1
+
+ self.send_response(206)
+ self.send_header('Content-Type', content_type)
+ self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
+ self.send_header('Content-Length', str(length))
+ self.send_header('Accept-Ranges', 'bytes')
+ self.end_headers()
+
+ with open(path, 'rb') as fh:
+ fh.seek(start)
+ remaining = length
+ while remaining > 0:
+ chunk = fh.read(min(65536, remaining))
+ if not chunk:
+ break
+ self.wfile.write(chunk)
+ remaining -= len(chunk)
+ else:
+ self.send_response(200)
+ self.send_header('Content-Type', content_type)
+ self.send_header('Content-Length', str(size))
+ self.send_header('Accept-Ranges', 'bytes')
+ self.end_headers()
+
+ with open(path, 'rb') as fh:
+ while True:
+ chunk = fh.read(65536)
+ if not chunk:
+ break
+ self.wfile.write(chunk)
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
+ path.relative_to(base)
except (ValueError, Exception):
self._send(403, b'Forbidden', 'text/plain')
return None
@@ -281,11 +391,11 @@ class _Handler(BaseHTTPRequestHandler):
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
def log_message(self, fmt, *args):
- pass # suppress default access log noise
+ pass
# ---------------------------------------------------------------------------
-# Embedded HTML/CSS/JS
+# Embedded HTML / CSS / JS
# ---------------------------------------------------------------------------
_HTML = r"""
@@ -296,180 +406,323 @@ _HTML = r"""
ISR Archive
+Skip to content
ISR Archive
- Loading…
+ Loading…
+
-
-
+
+
- | File |
- Date |
- Duration |
- Size |
- Waveform / Loud sections |
- |
+ File |
+ Date |
+ Duration |
+ Size |
+ Loudness |
+ Actions |
-
No recordings found.
+
No recordings found.