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.
|
||||
"""
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user