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 +

ISR Archive

- Loading… + Loading… +
-
- +
+
- - - - - - + + + + + +
FileDateDurationSizeWaveform / Loud sectionsFileDateDurationSizeLoudnessActions
- +
""" @@ -505,7 +758,9 @@ def main(): 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("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable") + elif not SOUNDFILE_AVAILABLE: + print("Note: soundfile not installed — FLAC loudness analysis unavailable") print("Stop with Ctrl+C\n") try: