feat: FLAC analysis, inline player, WCAG, live-recording status
web.py: - Extend loudness analysis to FLAC files via soundfile (numpy required) - Add /stream/ endpoint with HTTP Range support for seekable inline playback - Add collapsible ▶ Play button per row (hidden by default); src loaded lazily - Add /api/status endpoint returning active filenames from status.json - Animated ● REC badge on in-progress files, polled every 5 s - Full WCAG: skip link, aria-expanded/controls, aria-label, role=img on waveform SVG, role=list on loud-section chips, focus-visible outlines, aria-live on subtitle, focus moved to <audio> when player opens isr.py: - Write recordings/status.json atomically every 2 s while recording - Delete status.json on clean shutdown so web UI shows no stale state
This commit is contained in:
@@ -5,6 +5,7 @@ Records from multiple sources: Icecast streams and soundcards.
|
|||||||
Supports time-based file splitting and concurrent recording.
|
Supports time-based file splitting and concurrent recording.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -787,6 +788,8 @@ class RecorderManager:
|
|||||||
self.threads: List[threading.Thread] = []
|
self.threads: List[threading.Thread] = []
|
||||||
self.logger = None
|
self.logger = None
|
||||||
self.audio_system = None
|
self.audio_system = None
|
||||||
|
self.output_dir = 'recordings'
|
||||||
|
self._status_running = False
|
||||||
|
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
@@ -812,6 +815,8 @@ class RecorderManager:
|
|||||||
'log_file': config.get('general', 'log_file', fallback='recorder.log'),
|
'log_file': config.get('general', 'log_file', fallback='recorder.log'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.output_dir = general['output_directory']
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
self._setup_logging(general['log_level'], general['log_file'])
|
self._setup_logging(general['log_level'], general['log_file'])
|
||||||
|
|
||||||
@@ -877,6 +882,32 @@ class RecorderManager:
|
|||||||
)
|
)
|
||||||
self.logger = logging.getLogger(__name__)
|
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):
|
def start(self):
|
||||||
"""Start all recorders in separate threads."""
|
"""Start all recorders in separate threads."""
|
||||||
if not self.recorders:
|
if not self.recorders:
|
||||||
@@ -891,12 +922,15 @@ class RecorderManager:
|
|||||||
self.threads.append(thread)
|
self.threads.append(thread)
|
||||||
thread.start()
|
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")
|
self.logger.info("All recorders started")
|
||||||
|
|
||||||
# Wait for interrupt
|
# Wait for interrupt
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Check if all threads are still alive
|
|
||||||
alive = [t for t in self.threads if t.is_alive()]
|
alive = [t for t in self.threads if t.is_alive()]
|
||||||
if not alive:
|
if not alive:
|
||||||
self.logger.info("All recorders have stopped")
|
self.logger.info("All recorders have stopped")
|
||||||
@@ -908,13 +942,21 @@ class RecorderManager:
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop all recorders gracefully."""
|
"""Stop all recorders gracefully."""
|
||||||
|
self._status_running = False
|
||||||
for recorder in self.recorders:
|
for recorder in self.recorders:
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
|
|
||||||
# Wait for threads to finish
|
|
||||||
for thread in self.threads:
|
for thread in self.threads:
|
||||||
thread.join(timeout=5)
|
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")
|
self.logger.info("All recorders stopped")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"""
|
"""
|
||||||
ISR Web — Browse and download recorded audio files.
|
ISR Web — Browse and download recorded audio files.
|
||||||
|
|
||||||
Shows a chronological table of all recordings, allows download,
|
Shows a chronological table of all recordings, allows inline playback,
|
||||||
and analyses WAV files for loud sections using RMS.
|
download, and analyses WAV/FLAC files for loud sections using RMS.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python web.py # serves recordings/ on port 8080
|
python web.py # serves recordings/ on port 8080
|
||||||
@@ -16,6 +16,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import struct
|
import struct
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -29,6 +30,12 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
NUMPY_AVAILABLE = False
|
NUMPY_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
SOUNDFILE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
SOUNDFILE_AVAILABLE = False
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants
|
# 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"
|
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
|
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):
|
def _get_wav_info(path: Path):
|
||||||
@@ -52,9 +68,9 @@ def _get_wav_info(path: Path):
|
|||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
def _compute_rms_windows(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 (time_seconds, 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."""
|
||||||
frame_pos = 0
|
frame_pos = 0
|
||||||
while True:
|
while True:
|
||||||
raw = wf.readframes(window_samples)
|
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
|
mono = samples[::channels] if channels > 1 else samples
|
||||||
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
|
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
|
frame_pos += window_samples
|
||||||
|
|
||||||
|
|
||||||
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||||
threshold: float = LOUD_THRESHOLD):
|
threshold: float) -> list:
|
||||||
"""
|
sections = []
|
||||||
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
|
start_t = None
|
||||||
last_loud_t = None
|
last_loud_t = None
|
||||||
|
|
||||||
@@ -125,26 +108,80 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|||||||
start_t = t
|
start_t = t
|
||||||
last_loud_t = t
|
last_loud_t = t
|
||||||
else:
|
else:
|
||||||
if start_t is not None:
|
if start_t is not None and (t - last_loud_t) > MIN_GAP_SECONDS:
|
||||||
gap = t - last_loud_t
|
sections.append({'start': round(start_t, 1),
|
||||||
if gap > MIN_GAP_SECONDS:
|
'end': round(last_loud_t + window_dur, 1)})
|
||||||
sections.append({'start': round(start_t, 1),
|
start_t = None
|
||||||
'end': round(last_loud_t + window_dur, 1)})
|
last_loud_t = None
|
||||||
start_t = None
|
|
||||||
last_loud_t = None
|
|
||||||
|
|
||||||
if start_t is not None:
|
if start_t is not None:
|
||||||
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)})
|
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 {
|
return {
|
||||||
'rms': rms_values,
|
'rms': rms_values,
|
||||||
'rms_display': rms_display,
|
'rms_display': rms_display,
|
||||||
'sections': sections,
|
'sections': _loud_sections(rms_values, window_dur, duration, threshold),
|
||||||
'duration': round(duration, 2),
|
'duration': round(duration, 2),
|
||||||
'window': round(window_dur, 4),
|
'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
|
# File listing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,25 +192,35 @@ def list_files(recordings_dir: str):
|
|||||||
if not base.exists():
|
if not base.exists():
|
||||||
return []
|
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 = []
|
files = []
|
||||||
for path in base.rglob('*'):
|
for path in base.rglob('*'):
|
||||||
if path.suffix.lower() not in AUDIO_EXTENSIONS:
|
if path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
rel = path.relative_to(base)
|
rel = str(path.relative_to(base)).replace('\\', '/')
|
||||||
|
|
||||||
|
duration = None
|
||||||
if path.suffix.lower() == '.wav':
|
if path.suffix.lower() == '.wav':
|
||||||
duration, _, _ = _get_wav_info(path)
|
duration, _, _ = _get_wav_info(path)
|
||||||
else:
|
|
||||||
duration = None
|
|
||||||
|
|
||||||
files.append({
|
files.append({
|
||||||
'name': str(rel).replace('\\', '/'),
|
'name': rel,
|
||||||
'size': stat.st_size,
|
'size': stat.st_size,
|
||||||
'mtime': stat.st_mtime,
|
'mtime': stat.st_mtime,
|
||||||
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'ext': path.suffix.lower().lstrip('.'),
|
'ext': path.suffix.lower().lstrip('.'),
|
||||||
|
'recording': rel in active_files,
|
||||||
})
|
})
|
||||||
|
|
||||||
files.sort(key=lambda f: f['mtime'], reverse=True)
|
files.sort(key=lambda f: f['mtime'], reverse=True)
|
||||||
@@ -188,8 +235,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
threshold: float = LOUD_THRESHOLD
|
threshold: float = LOUD_THRESHOLD
|
||||||
|
|
||||||
# ---- routing -----------------------------------------------------------
|
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
@@ -201,13 +246,15 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._api_files()
|
self._api_files()
|
||||||
elif p == '/api/analyze':
|
elif p == '/api/analyze':
|
||||||
self._api_analyze(qs)
|
self._api_analyze(qs)
|
||||||
|
elif p == '/api/status':
|
||||||
|
self._api_status()
|
||||||
elif p.startswith('/download/'):
|
elif p.startswith('/download/'):
|
||||||
self._download(unquote(p[len('/download/'):]))
|
self._download(unquote(p[len('/download/'):]))
|
||||||
|
elif p.startswith('/stream/'):
|
||||||
|
self._stream(unquote(p[len('/stream/'):]))
|
||||||
else:
|
else:
|
||||||
self._send(404, b'Not found', 'text/plain')
|
self._send(404, b'Not found', 'text/plain')
|
||||||
|
|
||||||
# ---- endpoints ---------------------------------------------------------
|
|
||||||
|
|
||||||
def _html(self):
|
def _html(self):
|
||||||
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
|
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
|
||||||
|
|
||||||
@@ -225,13 +272,29 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
if path is None:
|
if path is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if path.suffix.lower() != '.wav':
|
ext = path.suffix.lower()
|
||||||
self._json_err(400, 'loudness analysis is only available for WAV files')
|
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
|
return
|
||||||
|
|
||||||
result = analyze_wav(path, threshold=self.threshold)
|
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
|
||||||
data = json.dumps(result).encode('utf-8')
|
|
||||||
self._send(200, data, '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):
|
def _download(self, filename: str):
|
||||||
path = self._safe_path(filename)
|
path = self._safe_path(filename)
|
||||||
@@ -252,14 +315,61 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
break
|
break
|
||||||
self.wfile.write(chunk)
|
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):
|
def _safe_path(self, filename: str):
|
||||||
"""Resolve filename within recordings_dir; return None and send error if invalid."""
|
|
||||||
base = Path(self.recordings_dir).resolve()
|
base = Path(self.recordings_dir).resolve()
|
||||||
try:
|
try:
|
||||||
path = (base / filename).resolve()
|
path = (base / filename).resolve()
|
||||||
path.relative_to(base) # raises ValueError if outside base
|
path.relative_to(base)
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
self._send(403, b'Forbidden', 'text/plain')
|
self._send(403, b'Forbidden', 'text/plain')
|
||||||
return None
|
return None
|
||||||
@@ -281,11 +391,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
|
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
def log_message(self, fmt, *args):
|
||||||
pass # suppress default access log noise
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Embedded HTML/CSS/JS
|
# Embedded HTML / CSS / JS
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_HTML = r"""<!DOCTYPE html>
|
_HTML = r"""<!DOCTYPE html>
|
||||||
@@ -296,180 +406,323 @@ _HTML = r"""<!DOCTYPE html>
|
|||||||
<title>ISR Archive</title>
|
<title>ISR Archive</title>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
|
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
|
||||||
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;}
|
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;}
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
|
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}
|
/* skip link */
|
||||||
|
.skip{position:absolute;left:-999px;top:0;padding:6px 14px;background:var(--accent);
|
||||||
|
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-size:13px;font-weight:600}
|
||||||
|
.skip:focus{left:0}
|
||||||
|
/* sr-only */
|
||||||
|
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
|
||||||
|
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||||
|
header{padding:20px 28px;border-bottom:1px solid var(--brd);
|
||||||
|
display:flex;align-items:baseline;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}
|
#subtitle{color:var(--muted);font-size:13px;margin-right:auto}
|
||||||
.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;
|
||||||
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);white-space:nowrap}
|
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}
|
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
|
||||||
tr:last-child td{border-bottom:none}
|
tr.data-row:hover td{background:var(--surf)}
|
||||||
tr:hover td{background:var(--surf)}
|
.fn{font-family:ui-monospace,monospace;font-size:12px}
|
||||||
.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;
|
.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}
|
font-weight:700;text-transform:uppercase;margin-right:4px;border:1px solid}
|
||||||
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
||||||
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||||
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
||||||
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
||||||
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
||||||
|
.badge-rec{display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:3px;
|
||||||
|
font-size:10px;font-weight:700;text-transform:uppercase;margin-right:4px;
|
||||||
|
color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
|
||||||
|
animation:pulse 1.5s ease-in-out infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||||||
.muted{color:var(--muted)}
|
.muted{color:var(--muted)}
|
||||||
btn,button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
|
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}
|
color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
|
||||||
button:hover{background:var(--brd)}
|
button:hover{background:var(--brd)}
|
||||||
|
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
button:disabled{opacity:.5;cursor:default}
|
||||||
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
|
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
|
||||||
a.dl:hover{text-decoration:underline}
|
a.dl:hover{text-decoration:underline}
|
||||||
/* waveform row */
|
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||||||
.wrow td{padding:0 10px 14px;background:var(--bg)}
|
.actions{display:flex;gap:6px;align-items:center}
|
||||||
|
/* waveform */
|
||||||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
||||||
svg.wave{display:block;width:100%;height:56px}
|
svg.wave{display:block;width:100%;height:56px}
|
||||||
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||||||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
||||||
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
||||||
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
||||||
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
||||||
.empty{text-align:center;padding:60px;color:var(--muted)}
|
.empty{text-align:center;padding:60px;color:var(--muted)}
|
||||||
|
/* player row */
|
||||||
|
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
||||||
|
audio{width:100%;height:36px;border-radius:4px;display:block;
|
||||||
|
color-scheme:dark;accent-color:var(--accent)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a href="#main" class="skip">Skip to content</a>
|
||||||
<header>
|
<header>
|
||||||
<h1>ISR Archive</h1>
|
<h1>ISR Archive</h1>
|
||||||
<span id="subtitle">Loading…</span>
|
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
|
||||||
|
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap" id="main">
|
||||||
<table>
|
<table aria-label="Recordings archive">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>File</th>
|
<th scope="col">File</th>
|
||||||
<th>Date</th>
|
<th scope="col">Date</th>
|
||||||
<th>Duration</th>
|
<th scope="col">Duration</th>
|
||||||
<th>Size</th>
|
<th scope="col">Size</th>
|
||||||
<th>Waveform / Loud sections</th>
|
<th scope="col">Loudness</th>
|
||||||
<th></th>
|
<th scope="col"><span class="sr">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tbody"></tbody>
|
<tbody id="tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div id="empty" class="empty" style="display:none">No recordings found.</div>
|
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
const esc = s => String(s)
|
||||||
|
.replace(/&/g,'&').replace(/</g,'<')
|
||||||
|
.replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
|
||||||
const fmtDur = s => {
|
const fmtDur = s => {
|
||||||
if (s == null) return '—';
|
if (s == null) return '—';
|
||||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
|
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)}`;
|
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
|
||||||
};
|
};
|
||||||
const fmtSize = b => {
|
const fmtSize = b => {
|
||||||
if (b < 1024) return b+' B';
|
if (b<1024) return b+' B';
|
||||||
if (b < 1<<20) return (b/1024).toFixed(0)+' KB';
|
if (b<1<<20) return (b/1024).toFixed(0)+' KB';
|
||||||
if (b < 1<<30) return (b/(1<<20)).toFixed(1)+' MB';
|
if (b<1<<30) return (b/(1<<20)).toFixed(1)+' MB';
|
||||||
return (b/(1<<30)).toFixed(2)+' GB';
|
return (b/(1<<30)).toFixed(2)+' GB';
|
||||||
};
|
};
|
||||||
const fmtT = s => {
|
const fmtT = s => {
|
||||||
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
|
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)}`;
|
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
|
||||||
};
|
};
|
||||||
const p = n => String(n).padStart(2,'0');
|
const pad = n => String(n).padStart(2,'0');
|
||||||
|
|
||||||
function drawWave(rms, sections, duration) {
|
// idx -> filename, for live-status polling
|
||||||
const ns = 'http://www.w3.org/2000/svg';
|
const recMap = new Map();
|
||||||
const svg = document.createElementNS(ns, 'svg');
|
|
||||||
|
function togglePlayer(idx, filename) {
|
||||||
|
const prow = document.getElementById('prow-'+idx);
|
||||||
|
const btn = document.getElementById('pbtn-'+idx);
|
||||||
|
const audio = document.getElementById('aud-'+idx);
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
if (!audio.getAttribute('data-src-set')) {
|
||||||
|
audio.src = '/stream/' + encodeURIComponent(filename);
|
||||||
|
audio.setAttribute('data-src-set','1');
|
||||||
|
}
|
||||||
|
prow.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded','true');
|
||||||
|
btn.textContent = '⏹ Hide';
|
||||||
|
btn.setAttribute('aria-label','Hide player for '+filename);
|
||||||
|
// Move focus to audio control so keyboard users can operate it immediately
|
||||||
|
audio.focus();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
prow.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded','false');
|
||||||
|
btn.textContent = '▶ Play';
|
||||||
|
btn.setAttribute('aria-label','Play '+filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWave(rms, sections, duration, filename) {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(ns,'svg');
|
||||||
svg.setAttribute('class','wave');
|
svg.setAttribute('class','wave');
|
||||||
svg.setAttribute('viewBox', `0 0 ${rms.length} 1`);
|
svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
|
||||||
svg.setAttribute('preserveAspectRatio','none');
|
svg.setAttribute('preserveAspectRatio','none');
|
||||||
|
svg.setAttribute('role','img');
|
||||||
|
const nSec = sections ? sections.length : 0;
|
||||||
|
svg.setAttribute('aria-label',
|
||||||
|
`Waveform for ${filename}: duration ${fmtDur(duration)}, ${nSec} loud section${nSec!==1?'s':''}`);
|
||||||
|
|
||||||
// highlight loud sections
|
if (duration > 0 && sections) {
|
||||||
if (duration > 0) {
|
|
||||||
sections.forEach(s => {
|
sections.forEach(s => {
|
||||||
const r = document.createElementNS(ns, 'rect');
|
const r = document.createElementNS(ns,'rect');
|
||||||
r.setAttribute('x', (s.start/duration)*rms.length);
|
r.setAttribute('x', (s.start/duration)*rms.length);
|
||||||
r.setAttribute('y', 0);
|
r.setAttribute('y', 0);
|
||||||
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
|
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
|
||||||
r.setAttribute('height', 1);
|
r.setAttribute('height', 1);
|
||||||
r.setAttribute('fill','rgba(249,115,22,0.22)');
|
r.setAttribute('fill','rgba(249,115,22,0.22)');
|
||||||
|
r.setAttribute('aria-hidden','true');
|
||||||
svg.appendChild(r);
|
svg.appendChild(r);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxV = Math.max(...rms, 0.001);
|
const maxV = Math.max(...rms, 0.001);
|
||||||
rms.forEach((v,i) => {
|
rms.forEach((v,i) => {
|
||||||
const h = v/maxV;
|
const h = v/maxV;
|
||||||
const r = document.createElementNS(ns,'rect');
|
const r = document.createElementNS(ns,'rect');
|
||||||
r.setAttribute('x', i); r.setAttribute('y', 1-h);
|
r.setAttribute('x',i); r.setAttribute('y',1-h);
|
||||||
r.setAttribute('width',1); r.setAttribute('height',h);
|
r.setAttribute('width',1); r.setAttribute('height',h);
|
||||||
r.setAttribute('fill','#4f9cf9');
|
r.setAttribute('fill','#4f9cf9');
|
||||||
|
r.setAttribute('aria-hidden','true');
|
||||||
svg.appendChild(r);
|
svg.appendChild(r);
|
||||||
});
|
});
|
||||||
|
|
||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyse(filename, cell, btn) {
|
async function analyse(filename, cell, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '…';
|
btn.textContent = '…';
|
||||||
cell.innerHTML = '<div class="spin">Analysing…</div>';
|
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename));
|
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename));
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.error) { cell.innerHTML=`<div class="spin">Error: ${d.error}</div>`; return; }
|
if (d.error) {
|
||||||
|
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||||||
|
btn.disabled = false; btn.textContent = 'Analyse';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const box = document.createElement('div'); box.className='wbox';
|
const box = document.createElement('div'); box.className='wbox';
|
||||||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0));
|
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||||||
|
|
||||||
const chips = document.createElement('div'); chips.className='chips';
|
const chips = document.createElement('div');
|
||||||
|
chips.className='chips';
|
||||||
|
chips.setAttribute('role','list');
|
||||||
|
chips.setAttribute('aria-label','Loud sections');
|
||||||
if (d.sections && d.sections.length) {
|
if (d.sections && d.sections.length) {
|
||||||
d.sections.forEach(s => {
|
d.sections.forEach(s => {
|
||||||
const c=document.createElement('span'); c.className='chip';
|
const c = document.createElement('span');
|
||||||
c.textContent=`${fmtT(s.start)} – ${fmtT(s.end)}`;
|
c.className='chip'; c.setAttribute('role','listitem');
|
||||||
|
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
||||||
chips.appendChild(c);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
chips.innerHTML='<span class="quiet">No loud sections</span>';
|
const q = document.createElement('span');
|
||||||
|
q.className='quiet'; q.setAttribute('role','listitem');
|
||||||
|
q.textContent='No loud sections found';
|
||||||
|
chips.appendChild(q);
|
||||||
}
|
}
|
||||||
box.appendChild(chips);
|
box.appendChild(chips);
|
||||||
cell.innerHTML=''; cell.appendChild(box);
|
cell.innerHTML=''; cell.appendChild(box);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
cell.innerHTML=`<div class="spin">Error: ${e.message}</div>`;
|
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`;
|
||||||
|
btn.disabled = false; btn.textContent = 'Analyse';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const files = await (await fetch('/api/files')).json();
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
const tbody = document.getElementById('tbody');
|
refreshBtn.disabled = true;
|
||||||
document.getElementById('subtitle').textContent =
|
document.getElementById('subtitle').textContent = 'Loading…';
|
||||||
`${files.length} recording${files.length!==1?'s':''} found`;
|
recMap.clear();
|
||||||
if (!files.length) { document.getElementById('empty').style.display=''; return; }
|
|
||||||
|
|
||||||
files.forEach(f => {
|
let files;
|
||||||
const ext = f.ext;
|
try {
|
||||||
|
files = await (await fetch('/api/files')).json();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('subtitle').textContent = 'Error loading files';
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbody = document.getElementById('tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
const n = files.length;
|
||||||
|
document.getElementById('subtitle').textContent =
|
||||||
|
`${n} recording${n!==1?'s':''} found`;
|
||||||
|
document.getElementById('empty').style.display = n ? 'none' : '';
|
||||||
|
if (!n) { refreshBtn.disabled = false; return; }
|
||||||
|
|
||||||
|
files.forEach((f, i) => {
|
||||||
|
const ext = f.ext;
|
||||||
|
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||||
|
const isRec = !!f.recording;
|
||||||
|
|
||||||
|
// ---- main data row ----
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const waveCell = ext==='wav'
|
tr.className = 'data-row';
|
||||||
? `<td class="wave-cell"></td>`
|
tr.id = 'row-'+i;
|
||||||
: `<td><span class="muted" style="font-size:12px">WAV files only</span></td>`;
|
|
||||||
|
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
|
||||||
|
aria-label="Currently recording" aria-hidden="${isRec?'false':'true'}">
|
||||||
|
<span aria-hidden="true">●</span> REC</span>`;
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td><span class="badge badge-${ext}">${ext}</span><span class="fn">${f.name}</span></td>
|
<td>
|
||||||
<td class="muted" style="white-space:nowrap">${f.date}</td>
|
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
|
||||||
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
|
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
|
||||||
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
||||||
${waveCell}
|
<td id="wave-${i}">${canAnalyse ? '' :
|
||||||
<td><a class="dl" href="/download/${encodeURIComponent(f.name)}">⇓ Download</a></td>`;
|
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="pbtn-${i}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="prow-${i}"
|
||||||
|
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||||||
|
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||||||
|
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
|
|
||||||
if (ext === 'wav') {
|
// ---- player row (hidden by default) ----
|
||||||
const cell = tr.querySelector('.wave-cell');
|
const prow = document.createElement('tr');
|
||||||
const btn = document.createElement('button');
|
prow.className = 'player-row';
|
||||||
btn.textContent = 'Analyse';
|
prow.id = 'prow-'+i;
|
||||||
btn.addEventListener('click', () => analyse(f.name, cell, btn));
|
prow.hidden = true;
|
||||||
cell.appendChild(btn);
|
prow.innerHTML = `<td colspan="6">
|
||||||
|
<audio id="aud-${i}" controls preload="none"
|
||||||
|
aria-label="Playback: ${esc(f.name)}"></audio>
|
||||||
|
</td>`;
|
||||||
|
tbody.appendChild(prow);
|
||||||
|
|
||||||
|
// ---- attach analyse button ----
|
||||||
|
if (canAnalyse) {
|
||||||
|
const cell = document.getElementById('wave-'+i);
|
||||||
|
const abtn = document.createElement('button');
|
||||||
|
abtn.textContent = 'Analyse';
|
||||||
|
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
|
||||||
|
abtn.addEventListener('click', () => analyse(f.name, cell, abtn));
|
||||||
|
cell.appendChild(abtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- attach play button handler ----
|
||||||
|
document.getElementById('pbtn-'+i)
|
||||||
|
.addEventListener('click', () => togglePlayer(i, f.name));
|
||||||
|
|
||||||
|
// ---- register for live-status polling ----
|
||||||
|
recMap.set(i, f.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refreshBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
// Poll recording status every 5 s to update REC badges
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const s = await (await fetch('/api/status')).json();
|
||||||
|
const active = new Set(s.active || []);
|
||||||
|
recMap.forEach((filename, idx) => {
|
||||||
|
const badge = document.getElementById('rec-'+idx);
|
||||||
|
if (!badge) return;
|
||||||
|
const on = active.has(filename);
|
||||||
|
badge.hidden = !on;
|
||||||
|
badge.setAttribute('aria-hidden', on ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('refresh-btn').addEventListener('click', load);
|
||||||
|
load().then(() => setInterval(pollStatus, 5000));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -505,7 +758,9 @@ def main():
|
|||||||
print(f"Recordings dir → {rec_dir.resolve()}")
|
print(f"Recordings dir → {rec_dir.resolve()}")
|
||||||
print(f"Loud threshold → {args.threshold}")
|
print(f"Loud threshold → {args.threshold}")
|
||||||
if not NUMPY_AVAILABLE:
|
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")
|
print("Stop with Ctrl+C\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user