b6b328dfb8
preload='metadata' only fetches the header; every seek then requires a fresh Range request and buffering delay. Switching to 'auto' lets the browser start buffering the file immediately so seeking into it is fast. Set both in togglePlayer (on open) and in seekToSection/jumpToDaySection (in case the player was already open with the old metadata-only mode). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1674 lines
65 KiB
Python
1674 lines
65 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ISR Web — Browse and download recorded audio files.
|
||
|
||
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
|
||
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 re
|
||
import shutil
|
||
import struct
|
||
import subprocess
|
||
import tempfile
|
||
import wave
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
||
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
|
||
|
||
try:
|
||
import soundfile as sf
|
||
SOUNDFILE_AVAILABLE = True
|
||
except ImportError:
|
||
SOUNDFILE_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
|
||
|
||
MIME_TYPES = {
|
||
'.wav': 'audio/wav',
|
||
'.mp3': 'audio/mpeg',
|
||
'.ogg': 'audio/ogg',
|
||
'.flac': 'audio/flac',
|
||
'.aac': 'audio/aac',
|
||
'.opus': 'audio/ogg',
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Audio analysis 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 _get_audio_duration(path: Path):
|
||
"""Return duration in seconds for any supported audio file, or None."""
|
||
ext = path.suffix.lower()
|
||
if ext == '.wav':
|
||
dur, _, _ = _get_wav_info(path)
|
||
return dur
|
||
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
|
||
try:
|
||
with sf.SoundFile(path) as f:
|
||
return round(len(f) / f.samplerate, 2)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
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)
|
||
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 round(rms, 5)
|
||
frame_pos += window_samples
|
||
|
||
|
||
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list:
|
||
sections = []
|
||
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 and (t - last_loud_t) > min_gap:
|
||
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,
|
||
min_gap: float = MIN_GAP_SECONDS) -> 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': _loud_sections(rms_values, window_dur, duration, threshold, min_gap),
|
||
'duration': round(duration, 2),
|
||
'window': round(window_dur, 4),
|
||
}
|
||
|
||
|
||
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||
threshold: float = LOUD_THRESHOLD,
|
||
min_gap: float = MIN_GAP_SECONDS) -> 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, min_gap)
|
||
|
||
|
||
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||
threshold: float = LOUD_THRESHOLD,
|
||
min_gap: float = MIN_GAP_SECONDS) -> 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, min_gap)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Analysis cache helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _analysis_cache_path(base: Path, audio_path: Path) -> Path:
|
||
rel = audio_path.relative_to(base)
|
||
return base / 'analyses' / rel.parent / (rel.name + '.analysis.json')
|
||
|
||
|
||
def prune_orphan_analyses(base: Path):
|
||
analyses_dir = base / 'analyses'
|
||
if not analyses_dir.exists():
|
||
return
|
||
removed = 0
|
||
for cache in analyses_dir.rglob('*.analysis.json'):
|
||
rel = cache.relative_to(analyses_dir)
|
||
audio_path = base / rel.parent / rel.name[:-len('.analysis.json')]
|
||
if not audio_path.exists():
|
||
try:
|
||
cache.unlink()
|
||
removed += 1
|
||
except Exception:
|
||
pass
|
||
if removed:
|
||
print(f'Pruned {removed} orphaned analysis cache file(s)')
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 []
|
||
|
||
# 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 = str(path.relative_to(base)).replace('\\', '/')
|
||
is_active = rel in active_files
|
||
|
||
# Skip reading partial headers for in-progress files — the WAV nframes
|
||
# field and FLAC total_samples are both unfinalized while recording,
|
||
# producing wildly incorrect values (e.g. 53375995583:39:01).
|
||
duration = None if is_active else _get_audio_duration(path)
|
||
|
||
files.append({
|
||
'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': is_active,
|
||
})
|
||
|
||
files.sort(key=lambda f: f['mtime'], reverse=True)
|
||
return files
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTTP handler
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _Handler(BaseHTTPRequestHandler):
|
||
recordings_dir: str = 'recordings'
|
||
threshold: float = LOUD_THRESHOLD
|
||
min_gap: float = MIN_GAP_SECONDS
|
||
|
||
def do_DELETE(self):
|
||
parsed = urlparse(self.path)
|
||
p = parsed.path
|
||
if p.startswith('/api/files/'):
|
||
self._api_delete(unquote(p[len('/api/files/'):]))
|
||
else:
|
||
self._send(404, b'Not found', 'text/plain')
|
||
|
||
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 == '/api/status':
|
||
self._api_status()
|
||
elif p == '/api/storage':
|
||
self._api_storage()
|
||
elif p == '/api/config':
|
||
self._api_config()
|
||
elif p == '/api/cut':
|
||
self._api_cut(qs)
|
||
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')
|
||
|
||
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
|
||
|
||
try:
|
||
threshold = float(qs.get('threshold', [self.threshold])[0])
|
||
threshold = max(0.0, min(1.0, threshold))
|
||
except (ValueError, TypeError):
|
||
threshold = self.threshold
|
||
|
||
try:
|
||
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
|
||
min_gap = max(0.0, min(300.0, min_gap))
|
||
except (ValueError, TypeError):
|
||
min_gap = self.min_gap
|
||
|
||
status_path = Path(self.recordings_dir) / 'status.json'
|
||
try:
|
||
with open(status_path) as fh:
|
||
if filename in set(json.load(fh).get('active', [])):
|
||
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
base = Path(self.recordings_dir).resolve()
|
||
cache_path = _analysis_cache_path(base, path)
|
||
try:
|
||
cached = json.loads(cache_path.read_text('utf-8'))
|
||
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap:
|
||
payload = dict(cached['result']); payload['cached'] = True
|
||
self._send(200, json.dumps(payload).encode('utf-8'), 'application/json')
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
ext = path.suffix.lower()
|
||
if ext == '.wav':
|
||
result = analyze_wav(path, threshold=threshold, min_gap=min_gap)
|
||
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=threshold, min_gap=min_gap)
|
||
else:
|
||
self._json_err(400, f'Loudness analysis is not available for {ext} files')
|
||
return
|
||
|
||
try:
|
||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = cache_path.with_suffix('.tmp')
|
||
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
|
||
os.replace(tmp, cache_path)
|
||
except Exception:
|
||
pass
|
||
|
||
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)
|
||
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)
|
||
|
||
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 _api_storage(self):
|
||
base = Path(self.recordings_dir)
|
||
used = 0
|
||
if base.exists():
|
||
used = sum(
|
||
p.stat().st_size
|
||
for p in base.rglob('*')
|
||
if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
|
||
)
|
||
try:
|
||
du = shutil.disk_usage(str(base) if base.exists() else '.')
|
||
disk_free, disk_total = du.free, du.total
|
||
except Exception:
|
||
disk_free = disk_total = None
|
||
data = json.dumps({'used': used, 'disk_free': disk_free, 'disk_total': disk_total})
|
||
self._send(200, data.encode(), 'application/json')
|
||
|
||
def _api_config(self):
|
||
data = json.dumps({'threshold': self.threshold, 'min_gap': self.min_gap})
|
||
self._send(200, data.encode(), 'application/json')
|
||
|
||
def _api_delete(self, filename: str):
|
||
status_path = Path(self.recordings_dir) / 'status.json'
|
||
try:
|
||
with open(status_path) as fh:
|
||
if filename in set(json.load(fh).get('active', [])):
|
||
self._json_err(409, 'Cannot delete a file that is currently being recorded')
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
path = self._safe_path(filename)
|
||
if path is None:
|
||
return
|
||
|
||
try:
|
||
path.unlink()
|
||
except Exception as e:
|
||
self._json_err(500, f'Failed to delete: {e}')
|
||
return
|
||
|
||
try:
|
||
_analysis_cache_path(Path(self.recordings_dir).resolve(), path).unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
||
|
||
def _api_cut(self, qs):
|
||
filename = qs.get('file', [None])[0]
|
||
start_s = qs.get('start', [None])[0]
|
||
end_s = qs.get('end', [None])[0]
|
||
|
||
if not filename or start_s is None or end_s is None:
|
||
self._json_err(400, 'missing file, start, or end parameter')
|
||
return
|
||
|
||
try:
|
||
start = float(start_s)
|
||
end = float(end_s)
|
||
except (ValueError, TypeError):
|
||
self._json_err(400, 'start and end must be numbers (seconds)')
|
||
return
|
||
|
||
if start < 0 or end <= start:
|
||
self._json_err(400, 'start must be ≥ 0 and end must be > start')
|
||
return
|
||
|
||
path = self._safe_path(filename)
|
||
if path is None:
|
||
return
|
||
|
||
status_path = Path(self.recordings_dir) / 'status.json'
|
||
try:
|
||
with open(status_path) as fh:
|
||
if filename in set(json.load(fh).get('active', [])):
|
||
self._json_err(409, 'Cannot cut a file that is currently being recorded')
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
if not shutil.which('ffmpeg'):
|
||
self._json_err(500, 'ffmpeg is not available on this server')
|
||
return
|
||
|
||
ext = path.suffix.lower()
|
||
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
||
|
||
# For lossless formats, re-encode (not copy) so the container header
|
||
# is rewritten with the correct duration/size. For lossy formats,
|
||
# copy is fine — the audio stops at the right frame regardless.
|
||
_lossless = {'.wav': ['-c:a', 'pcm_s16le'], '.flac': ['-c:a', 'flac']}
|
||
codec_args = _lossless.get(ext, ['-c', 'copy'])
|
||
|
||
fd, tmp_path = tempfile.mkstemp(suffix=ext)
|
||
os.close(fd)
|
||
try:
|
||
cmd = ['ffmpeg', '-y',
|
||
'-i', str(path),
|
||
'-ss', str(start), '-to', str(end),
|
||
'-vn'] + codec_args + [tmp_path]
|
||
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
||
if result.returncode != 0:
|
||
err = result.stderr.decode('utf-8', errors='replace')[-400:]
|
||
self._json_err(500, f'ffmpeg error: {err}')
|
||
return
|
||
|
||
tmp_size = os.path.getsize(tmp_path)
|
||
content_type = MIME_TYPES.get(ext, 'application/octet-stream')
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', content_type)
|
||
self.send_header('Content-Disposition', f'attachment; filename="{out_name}"')
|
||
self.send_header('Content-Length', str(tmp_size))
|
||
self.end_headers()
|
||
with open(tmp_path, 'rb') as fh:
|
||
while True:
|
||
chunk = fh.read(65536)
|
||
if not chunk:
|
||
break
|
||
self.wfile.write(chunk)
|
||
except subprocess.TimeoutExpired:
|
||
self._json_err(504, 'ffmpeg timed out — file may be too large')
|
||
finally:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except Exception:
|
||
pass
|
||
|
||
def _safe_path(self, filename: str):
|
||
base = Path(self.recordings_dir).resolve()
|
||
try:
|
||
path = (base / filename).resolve()
|
||
path.relative_to(base)
|
||
except (ValueError, Exception):
|
||
self._send(403, b'Forbidden', 'text/plain')
|
||
return None
|
||
|
||
if not path.is_file():
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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;--red:#ef4444;}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
|
||
/* 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:16px 28px;border-bottom:1px solid var(--brd);
|
||
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
header h1{font-size:18px;font-weight:600}
|
||
#subtitle{color:var(--muted);font-size:13px}
|
||
#storage-info{color:var(--muted);font-size:12px;margin-right:auto}
|
||
.controls-bar{display:flex;align-items:center;gap:10px;padding:10px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.controls-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.controls-bar input[type=number]{width:70px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.controls-bar input[type=number]:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
.controls-hint{font-size:11px;color:var(--muted)}
|
||
.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.data-row:hover td{background:var(--surf)}
|
||
.fn{font-family:ui-monospace,monospace;font-size:12px}
|
||
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
|
||
font-weight:700;text-transform:uppercase;margin-right:4px;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)}
|
||
.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)}
|
||
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
|
||
color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
|
||
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:hover{text-decoration:underline}
|
||
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||
.actions{display:flex;gap:6px;align-items:center}
|
||
button.del{color:var(--red);border-color:#7f1d1d}
|
||
button.del:hover:not(:disabled){background:#2d0808}
|
||
/* waveform */
|
||
.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}
|
||
button.chip{cursor:pointer}
|
||
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
||
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
||
.prog-bar{height:3px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
|
||
.prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 0.15s ease}
|
||
.prog-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||
.prog-tally{font-size:11px;color:var(--muted);margin-top:3px}
|
||
.cached-badge{font-size:10px;color:var(--muted);background:var(--surf);border:1px solid var(--brd);border-radius:3px;padding:1px 5px;margin-left:6px;vertical-align:middle;font-style:normal}
|
||
.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)}
|
||
/* cut panel */
|
||
.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;
|
||
padding-top:8px;border-top:1px solid var(--brd)}
|
||
.cut-label{font-size:12px;color:var(--muted);white-space:nowrap}
|
||
.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)}
|
||
.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt);
|
||
padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace}
|
||
.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||
/* filter bar */
|
||
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
||
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
/* day sections */
|
||
.day-section{margin-bottom:18px}
|
||
.day-heading-bar{background:var(--surf);padding:7px 10px;border:1px solid var(--brd);
|
||
border-radius:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.day-heading-bar.open{border-radius:6px 6px 0 0}
|
||
.day-toggle{background:none;border:none;color:var(--txt);font-size:13px;font-weight:600;
|
||
cursor:pointer;padding:2px 0;display:inline-flex;align-items:center;gap:8px;flex:1 1 auto}
|
||
.day-toggle:hover{color:var(--accent)}
|
||
.day-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||
.day-meta{color:var(--muted);font-size:12px;font-weight:400}
|
||
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px}
|
||
button.day-hl:hover:not(:disabled){background:#0a3d1f}
|
||
button.day-hl:disabled{opacity:.5;cursor:default}
|
||
h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit;flex:1 1 auto}
|
||
.day-hl-container{background:var(--bg);border:1px solid var(--brd);border-top:none;padding:8px 12px 12px}
|
||
table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);border-top:none}
|
||
svg.day-timeline{display:block;width:100%;height:22px}
|
||
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
|
||
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||
<a href="#main" class="skip">Skip to content</a>
|
||
<header>
|
||
<h1>ISR Archive</h1>
|
||
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
|
||
<span id="storage-info" aria-live="polite"></span>
|
||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||
</header>
|
||
<div class="controls-bar">
|
||
<label for="threshold-input">Analysis threshold:</label>
|
||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||
aria-describedby="threshold-hint">
|
||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
||
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||
aria-describedby="preroll-hint">
|
||
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
||
<label for="min-gap-input" style="margin-left:16px">Grace period:</label>
|
||
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
|
||
aria-describedby="min-gap-hint">
|
||
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
|
||
</div>
|
||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||
<label for="filter-name">Search:</label>
|
||
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
|
||
<label for="filter-from">From:</label>
|
||
<input type="date" id="filter-from" aria-label="From date">
|
||
<label for="filter-to">To:</label>
|
||
<input type="date" id="filter-to" aria-label="To date">
|
||
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||
</div>
|
||
<div class="wrap" id="main">
|
||
<div id="tbody" role="region" aria-label="Recordings archive"></div>
|
||
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
|
||
</div>
|
||
<script>
|
||
const esc = s => String(s)
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
|
||
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}:${pad(m)}:${pad(sec)}`:`${m}:${pad(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}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
|
||
};
|
||
const pad = n => String(n).padStart(2,'0');
|
||
|
||
function announce(msg) {
|
||
const el = document.getElementById('sr-announce');
|
||
if (!el) return;
|
||
el.textContent = ''; // clear first so same text re-triggers
|
||
setTimeout(() => { el.textContent = msg; }, 50);
|
||
}
|
||
const getPreroll = () => {
|
||
const v = parseFloat(document.getElementById('preroll-input').value);
|
||
return isNaN(v) || v < 0 ? 0 : v;
|
||
};
|
||
function setCutFields(idx, startSec, endSec) {
|
||
const startEl = document.getElementById('cut-start-'+idx);
|
||
const endEl = document.getElementById('cut-end-'+idx);
|
||
if (startEl) startEl.value = fmtT(startSec);
|
||
if (endEl && endSec != null) endEl.value = fmtT(endSec);
|
||
}
|
||
|
||
// idx -> filename, for live-status polling
|
||
const recMap = new Map();
|
||
// idx -> [{start,end}], populated after analysis
|
||
const sectionMap = new Map();
|
||
let activePlayerIdx = null;
|
||
// full file list from server, annotated with stable _idx
|
||
let allFiles = [];
|
||
// dayId -> boolean, persists expanded state across re-renders
|
||
const dayExpanded = new Map();
|
||
// cross-file day section navigation (populated by ★ Highlights)
|
||
let dayActiveSections = [];
|
||
let dayActiveSectionCursor = -1;
|
||
let dayActiveId = null;
|
||
|
||
function groupByDay(files) {
|
||
const map = new Map();
|
||
files.forEach(f => {
|
||
const day = f.date.slice(0, 10);
|
||
if (!map.has(day)) map.set(day, []);
|
||
map.get(day).push(f);
|
||
});
|
||
return map;
|
||
}
|
||
|
||
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.preload = 'auto';
|
||
audio.src = '/stream/' + encodeURIComponent(filename);
|
||
audio.load();
|
||
audio.setAttribute('data-src-set','1');
|
||
}
|
||
activePlayerIdx = idx;
|
||
prow.hidden = false;
|
||
btn.setAttribute('aria-expanded','true');
|
||
btn.textContent = '⏹ Hide';
|
||
btn.setAttribute('aria-label','Hide player for '+filename);
|
||
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('viewBox',`0 0 ${rms.length} 1`);
|
||
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':''}`);
|
||
|
||
if (duration > 0 && sections) {
|
||
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)');
|
||
r.setAttribute('aria-hidden','true');
|
||
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');
|
||
r.setAttribute('aria-hidden','true');
|
||
svg.appendChild(r);
|
||
});
|
||
return svg;
|
||
}
|
||
|
||
function parseTime(s) {
|
||
if (!s || !s.trim()) return null;
|
||
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||
if (parts.some(isNaN) || parts.length < 1 || parts.length > 3) return null;
|
||
if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
|
||
if (parts.length === 2) return parts[0]*60 + parts[1];
|
||
return parts[0];
|
||
}
|
||
|
||
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||
const pbtn = document.getElementById('pbtn-'+idx);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||
activePlayerIdx = idx;
|
||
const audio = document.getElementById('aud-'+idx);
|
||
audio.preload = 'auto';
|
||
const seekTo = Math.max(0, startSec - getPreroll());
|
||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||
if (audio.readyState >= 1) doSeek();
|
||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||
setCutFields(idx, startSec, endSec);
|
||
if (sectionIdx != null) {
|
||
const total = (sectionMap.get(idx) || []).length;
|
||
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtT(startSec)} to ${fmtT(endSec)}`);
|
||
}
|
||
}
|
||
|
||
async function analyse(idx, filename, cell, btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||
try {
|
||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||
+'&threshold='+encodeURIComponent(threshold)
|
||
+'&min_gap='+encodeURIComponent(minGap));
|
||
const d = await r.json();
|
||
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';
|
||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||
if (d.cached) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'cached-badge'; badge.textContent = 'cached';
|
||
badge.title = 'Result loaded from cache — change threshold/gap and re-analyse to recompute';
|
||
box.firstChild.after(badge);
|
||
}
|
||
|
||
const chips = document.createElement('div');
|
||
chips.className='chips';
|
||
chips.setAttribute('role','list');
|
||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||
if (d.sections && d.sections.length) {
|
||
sectionMap.set(idx, d.sections);
|
||
d.sections.forEach((s, si) => {
|
||
const c = document.createElement('button');
|
||
c.className='chip'; c.setAttribute('role','listitem');
|
||
c.title = 'Jump to this section (or use J/K keys)';
|
||
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||
chips.appendChild(c);
|
||
});
|
||
} else {
|
||
sectionMap.delete(idx);
|
||
const q = document.createElement('span');
|
||
q.className='quiet'; q.setAttribute('role','listitem');
|
||
q.textContent='No loud sections found';
|
||
chips.appendChild(q);
|
||
}
|
||
box.appendChild(chips);
|
||
cell.innerHTML=''; cell.appendChild(box);
|
||
} catch(e) {
|
||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`;
|
||
btn.disabled = false; btn.textContent = 'Analyse';
|
||
}
|
||
}
|
||
|
||
// J = previous section, K = next section (only when focus is not in an input)
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
|
||
e.preventDefault();
|
||
|
||
// Day-level cross-file navigation when Highlights have been loaded
|
||
if (dayActiveSections.length) {
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
const ni = dayActiveSectionCursor > 0 ? dayActiveSectionCursor - 1 : -1;
|
||
if (ni >= 0) jumpToDaySection(ni);
|
||
else announce('Beginning of day sections');
|
||
} else {
|
||
const ni = dayActiveSectionCursor + 1;
|
||
if (ni < dayActiveSections.length) jumpToDaySection(ni);
|
||
else announce('End of day sections');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Per-file section navigation
|
||
if (activePlayerIdx === null) return;
|
||
const sections = sectionMap.get(activePlayerIdx) || [];
|
||
if (!sections.length) return;
|
||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||
if (!audio) return;
|
||
const preroll = getPreroll();
|
||
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
const cur = audio.currentTime;
|
||
let targetIdx = -1;
|
||
for (let i = sections.length - 1; i >= 0; i--) {
|
||
if (sections[i].start < cur - 1) { targetIdx = i; break; }
|
||
}
|
||
if (targetIdx >= 0) {
|
||
const s = sections[targetIdx];
|
||
audio.currentTime = Math.max(0, s.start - preroll);
|
||
setCutFields(activePlayerIdx, s.start, s.end);
|
||
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
|
||
} else {
|
||
announce('Beginning of sections');
|
||
}
|
||
} else {
|
||
const cur = audio.currentTime;
|
||
let jumped = false;
|
||
for (let i = 0; i < sections.length; i++) {
|
||
if (sections[i].start > cur + preroll) {
|
||
const s = sections[i];
|
||
audio.currentTime = Math.max(0, s.start - preroll);
|
||
setCutFields(activePlayerIdx, s.start, s.end);
|
||
announce(`Section ${i + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
|
||
jumped = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!jumped) announce('End of sections');
|
||
}
|
||
});
|
||
|
||
async function deleteFile(idx, filename) {
|
||
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
||
const btn = document.getElementById('delbtn-'+idx);
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
||
if (r.ok) {
|
||
allFiles = allFiles.filter(f => f._idx !== idx);
|
||
recMap.delete(idx);
|
||
applyFilters();
|
||
updateStorage();
|
||
} else {
|
||
const d = await r.json().catch(()=>({}));
|
||
alert('Delete failed: '+(d.error||r.statusText));
|
||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||
}
|
||
} catch(e) {
|
||
alert('Delete failed: '+e.message);
|
||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||
}
|
||
}
|
||
|
||
async function updateStorage() {
|
||
try {
|
||
const s = await (await fetch('/api/storage')).json();
|
||
const el = document.getElementById('storage-info');
|
||
let txt = fmtSize(s.used) + ' used';
|
||
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
|
||
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
|
||
el.textContent = txt;
|
||
} catch(e) {}
|
||
}
|
||
|
||
function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||
const i = f._idx;
|
||
const ext = f.ext;
|
||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||
|
||
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}`);
|
||
if (isRec) {
|
||
abtn.disabled = true;
|
||
abtn.title = 'Recording in progress — analyse after recording stops';
|
||
} else {
|
||
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
|
||
}
|
||
cell.appendChild(abtn);
|
||
}
|
||
|
||
document.getElementById('pbtn-'+i)
|
||
.addEventListener('click', () => togglePlayer(i, f.name));
|
||
|
||
if (!isRec) {
|
||
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
|
||
const pbtn = document.getElementById('pbtn-'+i);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
|
||
document.getElementById('cut-start-'+i)?.focus();
|
||
});
|
||
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
|
||
const startStr = document.getElementById('cut-start-'+i).value;
|
||
const endStr = document.getElementById('cut-end-'+i).value;
|
||
const start = parseTime(startStr);
|
||
const end = parseTime(endStr);
|
||
if (start === null || end === null) {
|
||
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
|
||
return;
|
||
}
|
||
if (start >= end) { alert('Start must be before end'); return; }
|
||
window.location.href =
|
||
'/api/cut?file=' + encodeURIComponent(f.name) +
|
||
'&start=' + start + '&end=' + end;
|
||
});
|
||
document.getElementById('delbtn-'+i)
|
||
.addEventListener('click', () => deleteFile(i, f.name));
|
||
}
|
||
|
||
recMap.set(i, f.name);
|
||
}
|
||
|
||
function renderFiles(files) {
|
||
const container = document.getElementById('tbody');
|
||
container.innerHTML = '';
|
||
recMap.clear();
|
||
sectionMap.clear();
|
||
|
||
const total = allFiles.length;
|
||
const visible = files.length;
|
||
document.getElementById('subtitle').textContent = total === visible
|
||
? `${total} recording${total!==1?'s':''} found`
|
||
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||
|
||
const days = groupByDay(files);
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
days.forEach((dayFiles, day) => {
|
||
const dayId = 'day-' + day.replace(/-/g, '');
|
||
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === today);
|
||
const expanded = dayExpanded.get(dayId);
|
||
|
||
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
|
||
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
|
||
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
|
||
const sizeStr = ' · ' + fmtSize(totalSize);
|
||
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
|
||
|
||
// Day section wrapper
|
||
const section = document.createElement('div');
|
||
section.className = 'day-section';
|
||
section.id = 'daysec-' + dayId;
|
||
|
||
// Heading bar
|
||
const headBar = document.createElement('div');
|
||
headBar.className = 'day-heading-bar' + (expanded ? ' open' : '');
|
||
headBar.id = 'dayhead-' + dayId;
|
||
headBar.innerHTML = `
|
||
<h2 class="day-heading">
|
||
<button class="day-toggle" id="daytgl-${dayId}"
|
||
aria-expanded="${expanded}"
|
||
aria-controls="daytbl-${dayId}">
|
||
<span class="day-arrow" aria-hidden="true">${expanded ? '▼' : '▶'}</span>
|
||
${esc(day)}
|
||
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
|
||
</button>
|
||
</h2>
|
||
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
|
||
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}`;
|
||
section.appendChild(headBar);
|
||
|
||
// Highlights panel (hidden until button clicked)
|
||
const hlDiv = document.createElement('div');
|
||
hlDiv.className = 'day-hl-container';
|
||
hlDiv.id = 'dayhl-' + dayId;
|
||
hlDiv.hidden = true;
|
||
hlDiv.innerHTML = `<div id="dayhlc-${dayId}"></div>`;
|
||
section.appendChild(hlDiv);
|
||
|
||
// Per-day table
|
||
const table = document.createElement('table');
|
||
table.className = 'day-table';
|
||
table.id = 'daytbl-' + dayId;
|
||
table.setAttribute('aria-label', `Recordings for ${day}`);
|
||
if (!expanded) table.hidden = true;
|
||
table.innerHTML = `<thead><tr>
|
||
<th scope="col">File</th>
|
||
<th scope="col">Date</th>
|
||
<th scope="col">Duration</th>
|
||
<th scope="col">Size</th>
|
||
<th scope="col">Loudness</th>
|
||
<th scope="col"><span class="sr">Actions</span></th>
|
||
</tr></thead>`;
|
||
const dayTbody = document.createElement('tbody');
|
||
table.appendChild(dayTbody);
|
||
section.appendChild(table);
|
||
container.appendChild(section);
|
||
|
||
// File rows
|
||
dayFiles.forEach(f => {
|
||
const i = f._idx;
|
||
const ext = f.ext;
|
||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||
const isRec = !!f.recording;
|
||
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'data-row';
|
||
tr.id = 'row-'+i;
|
||
|
||
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 = `
|
||
<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 class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
||
<td id="wave-${i}">${canAnalyse ? '' :
|
||
'<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>
|
||
<button id="cutbtn-${i}" class="cut"
|
||
aria-label="Cut ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
||
<button id="delbtn-${i}" class="del"
|
||
aria-label="Delete ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||
</div>
|
||
</td>`;
|
||
dayTbody.appendChild(tr);
|
||
|
||
const prow = document.createElement('tr');
|
||
prow.className = 'player-row';
|
||
prow.id = 'prow-'+i;
|
||
prow.hidden = true;
|
||
const durLabel = f.duration != null
|
||
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
||
: '';
|
||
prow.innerHTML = `<td colspan="6">
|
||
<audio id="aud-${i}" controls preload="none"
|
||
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||
<div class="cut-panel">
|
||
<span class="cut-label">✂ Cut:</span>
|
||
<label class="cut-field">Start
|
||
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||
</label>
|
||
<label class="cut-field">End
|
||
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||
</label>
|
||
<button id="cut-dl-${i}" class="cut"
|
||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
||
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
||
</div>
|
||
</td>`;
|
||
dayTbody.appendChild(prow);
|
||
|
||
_attachFileRowHandlers(f, isRec, expanded, dayId);
|
||
});
|
||
|
||
// Day toggle handler
|
||
document.getElementById('daytgl-' + dayId).addEventListener('click', () => {
|
||
const nowExp = !dayExpanded.get(dayId);
|
||
dayExpanded.set(dayId, nowExp);
|
||
const tgl = document.getElementById('daytgl-' + dayId);
|
||
tgl.setAttribute('aria-expanded', nowExp);
|
||
tgl.querySelector('.day-arrow').textContent = nowExp ? '▼' : '▶';
|
||
headBar.classList.toggle('open', nowExp);
|
||
document.getElementById('daytbl-' + dayId).hidden = !nowExp;
|
||
if (!nowExp) {
|
||
dayFiles.forEach(f => {
|
||
const prow = document.getElementById('prow-' + f._idx);
|
||
if (prow && !prow.hidden) {
|
||
document.getElementById('aud-' + f._idx)?.pause();
|
||
prow.hidden = true;
|
||
const pbtn = document.getElementById('pbtn-' + f._idx);
|
||
if (pbtn) {
|
||
pbtn.setAttribute('aria-expanded', 'false');
|
||
pbtn.textContent = '▶ Play';
|
||
pbtn.setAttribute('aria-label', 'Play ' + f.name);
|
||
}
|
||
}
|
||
});
|
||
document.getElementById('dayhl-' + dayId).hidden = true;
|
||
if (dayActiveId === dayId) {
|
||
dayActiveSections = [];
|
||
dayActiveSectionCursor = -1;
|
||
dayActiveId = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Highlights button handler
|
||
if (canHl) {
|
||
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
|
||
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||
dayHighlights(dayId, hlFiles);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
async function dayHighlights(dayId, analyzableFiles) {
|
||
const hlRow = document.getElementById('dayhl-' + dayId);
|
||
const contentEl = document.getElementById('dayhlc-' + dayId);
|
||
const btn = document.getElementById('dayhln-' + dayId);
|
||
|
||
hlRow.hidden = false;
|
||
const n = analyzableFiles.length;
|
||
if (btn) btn.disabled = true;
|
||
|
||
// Build progress UI
|
||
const progWrap = document.createElement('div');
|
||
progWrap.setAttribute('aria-live', 'polite'); progWrap.setAttribute('aria-busy', 'true');
|
||
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
|
||
const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%';
|
||
progBar.appendChild(progFill);
|
||
const progFile = document.createElement('div'); progFile.className = 'prog-file';
|
||
const progTally = document.createElement('div'); progTally.className = 'prog-tally';
|
||
progWrap.appendChild(progBar); progWrap.appendChild(progFile); progWrap.appendChild(progTally);
|
||
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
|
||
|
||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||
|
||
const results = [];
|
||
let nCached = 0, nLive = 0;
|
||
for (let i = 0; i < analyzableFiles.length; i++) {
|
||
const f = analyzableFiles[i];
|
||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||
progFill.style.width = `${(i / n) * 100}%`;
|
||
const tallyParts = [];
|
||
if (nCached) tallyParts.push(`${nCached} cached`);
|
||
if (nLive) tallyParts.push(`${nLive} analysed`);
|
||
progTally.textContent = tallyParts.join(' · ');
|
||
try {
|
||
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
|
||
+ '&threshold=' + encodeURIComponent(threshold)
|
||
+ '&min_gap=' + encodeURIComponent(minGap));
|
||
const d = await r.json();
|
||
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||
} catch(e) {}
|
||
}
|
||
progFill.style.width = '100%';
|
||
progFile.textContent = `Done — ${n} file${n!==1?'s':''} (${nCached} cached, ${nLive} analysed)`;
|
||
progTally.textContent = '';
|
||
|
||
if (!results.length) {
|
||
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
|
||
if (btn) btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Map files onto the day timeline using mtime as file-end, duration for start
|
||
const positioned = results.map(({ f, data }) => {
|
||
const fileEnd = f.mtime;
|
||
const fileDur = data.duration || f.duration || 0;
|
||
const fileStart = fileEnd - fileDur;
|
||
return { f, data, fileStart, fileEnd, fileDur };
|
||
}).filter(r => r.fileDur > 0);
|
||
|
||
if (!positioned.length) {
|
||
contentEl.innerHTML = '<div class="quiet">Could not determine file time positions.</div>';
|
||
if (btn) btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
const minT = Math.min(...positioned.map(r => r.fileStart));
|
||
const maxT = Math.max(...positioned.map(r => r.fileEnd));
|
||
const spanT = maxT - minT || 1;
|
||
const W = 800, H = 22;
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
|
||
const svg = document.createElementNS(ns, 'svg');
|
||
svg.setAttribute('class', 'wave day-timeline');
|
||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||
svg.setAttribute('preserveAspectRatio', 'none');
|
||
svg.setAttribute('role', 'img');
|
||
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
|
||
svg.setAttribute('aria-label', `Day activity: ${results.length} file${results.length!==1?'s':''}, ${totalSecs} loud section${totalSecs!==1?'s':''}`);
|
||
|
||
// Background track
|
||
const bgR = document.createElementNS(ns, 'rect');
|
||
bgR.setAttribute('x', 0); bgR.setAttribute('y', 7);
|
||
bgR.setAttribute('width', W); bgR.setAttribute('height', 8);
|
||
bgR.setAttribute('fill', '#1e2535');
|
||
svg.appendChild(bgR);
|
||
|
||
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
|
||
const fx = ((fileStart - minT) / spanT) * W;
|
||
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
|
||
|
||
// File span (dim blue)
|
||
const fr = document.createElementNS(ns, 'rect');
|
||
fr.setAttribute('x', fx); fr.setAttribute('y', 8);
|
||
fr.setAttribute('width', fw); fr.setAttribute('height', 6);
|
||
fr.setAttribute('fill', '#1e3a5f');
|
||
fr.setAttribute('aria-hidden', 'true');
|
||
svg.appendChild(fr);
|
||
|
||
// Loud sections (orange)
|
||
(data.sections || []).forEach(s => {
|
||
const sx = fx + (s.start / fileDur) * fw;
|
||
const sw = Math.max(1, ((s.end - s.start) / fileDur) * fw);
|
||
const sr = document.createElementNS(ns, 'rect');
|
||
sr.setAttribute('x', sx); sr.setAttribute('y', 4);
|
||
sr.setAttribute('width', sw); sr.setAttribute('height', 14);
|
||
sr.setAttribute('fill', '#f97316');
|
||
sr.setAttribute('rx', '1');
|
||
sr.setAttribute('aria-hidden', 'true');
|
||
svg.appendChild(sr);
|
||
});
|
||
});
|
||
|
||
const fmtHM = ts => {
|
||
const d = new Date(ts * 1000);
|
||
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||
};
|
||
|
||
// Build cross-file section list for J/K navigation and chips
|
||
dayActiveSections = [];
|
||
positioned.forEach(({ f, data, fileStart }) => {
|
||
(data.sections || []).forEach(s => {
|
||
dayActiveSections.push({
|
||
fileIdx: f._idx,
|
||
filename: f.name,
|
||
start: s.start,
|
||
end: s.end,
|
||
absStart: fileStart + s.start,
|
||
});
|
||
});
|
||
});
|
||
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
|
||
dayActiveSectionCursor = -1;
|
||
dayActiveId = dayId;
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'wbox';
|
||
box.style.marginBottom = '4px';
|
||
box.appendChild(svg);
|
||
|
||
const labels = document.createElement('div');
|
||
labels.className = 'day-tl-labels';
|
||
labels.innerHTML = `<span>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
|
||
box.appendChild(labels);
|
||
|
||
if (dayActiveSections.length) {
|
||
const MAX_DAY_CHIPS = 50;
|
||
if (dayActiveSections.length > MAX_DAY_CHIPS) {
|
||
const note = document.createElement('p');
|
||
note.className = 'quiet';
|
||
note.style.marginTop = '6px';
|
||
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`;
|
||
box.appendChild(note);
|
||
} else {
|
||
const chips = document.createElement('div');
|
||
chips.className = 'chips';
|
||
chips.setAttribute('role', 'list');
|
||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
||
dayActiveSections.forEach((sec, si) => {
|
||
const c = document.createElement('button');
|
||
c.className = 'chip';
|
||
c.setAttribute('role', 'listitem');
|
||
c.title = sec.filename + ' @ ' + fmtT(sec.start);
|
||
const d = new Date(sec.absStart * 1000);
|
||
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
||
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
||
+ d.getSeconds().toString().padStart(2,'0');
|
||
c.textContent = hms;
|
||
c.addEventListener('click', () => jumpToDaySection(si));
|
||
chips.appendChild(c);
|
||
});
|
||
box.appendChild(chips);
|
||
}
|
||
}
|
||
|
||
const summary = document.createElement('div');
|
||
summary.className = 'quiet';
|
||
summary.style.marginTop = '4px';
|
||
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
|
||
box.appendChild(summary);
|
||
|
||
contentEl.innerHTML = '';
|
||
contentEl.appendChild(box);
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
function jumpToDaySection(si) {
|
||
if (si < 0 || si >= dayActiveSections.length) return;
|
||
dayActiveSectionCursor = si;
|
||
const sec = dayActiveSections[si];
|
||
const { fileIdx, filename, start, end } = sec;
|
||
|
||
// Close the previous player if switching to a different file
|
||
if (activePlayerIdx !== null && activePlayerIdx !== fileIdx) {
|
||
const prevProw = document.getElementById('prow-' + activePlayerIdx);
|
||
if (prevProw && !prevProw.hidden) {
|
||
document.getElementById('aud-' + activePlayerIdx)?.pause();
|
||
prevProw.hidden = true;
|
||
const prevPbtn = document.getElementById('pbtn-' + activePlayerIdx);
|
||
if (prevPbtn) {
|
||
prevPbtn.setAttribute('aria-expanded', 'false');
|
||
prevPbtn.textContent = '▶ Play';
|
||
prevPbtn.setAttribute('aria-label', 'Play ' + (recMap.get(activePlayerIdx) || ''));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Open this file's player
|
||
const pbtn = document.getElementById('pbtn-' + fileIdx);
|
||
if (pbtn && pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(fileIdx, filename);
|
||
activePlayerIdx = fileIdx;
|
||
|
||
const audio = document.getElementById('aud-' + fileIdx);
|
||
if (!audio) return;
|
||
audio.preload = 'auto';
|
||
const seekTo = Math.max(0, start - getPreroll());
|
||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||
if (audio.readyState >= 1) doSeek();
|
||
else audio.addEventListener('loadedmetadata', doSeek, { once: true });
|
||
|
||
setCutFields(fileIdx, start, end);
|
||
announce(`Day section ${si + 1} of ${dayActiveSections.length}: ${fmtT(start)}–${fmtT(end)} in ${filename}`);
|
||
}
|
||
|
||
function applyFilters() {
|
||
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||
const fromD = document.getElementById('filter-from').value;
|
||
const toD = document.getElementById('filter-to').value;
|
||
const filtered = allFiles.filter(f => {
|
||
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||
if (toD && f.date > toD + ' 23:59:59') return false;
|
||
return true;
|
||
});
|
||
renderFiles(filtered);
|
||
}
|
||
|
||
async function load() {
|
||
const refreshBtn = document.getElementById('refresh-btn');
|
||
refreshBtn.disabled = true;
|
||
document.getElementById('subtitle').textContent = 'Loading…';
|
||
|
||
let files;
|
||
try {
|
||
files = await (await fetch('/api/files')).json();
|
||
} catch(e) {
|
||
document.getElementById('subtitle').textContent = 'Error loading files';
|
||
refreshBtn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
allFiles = files.map((f, i) => ({...f, _idx: i}));
|
||
updateStorage();
|
||
applyFilters();
|
||
refreshBtn.disabled = false;
|
||
}
|
||
|
||
// 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);
|
||
|
||
document.getElementById('filter-name').addEventListener('input', applyFilters);
|
||
document.getElementById('filter-from').addEventListener('change', applyFilters);
|
||
document.getElementById('filter-to').addEventListener('change', applyFilters);
|
||
document.getElementById('filter-clear').addEventListener('click', () => {
|
||
document.getElementById('filter-name').value = '';
|
||
document.getElementById('filter-from').value = '';
|
||
document.getElementById('filter-to').value = '';
|
||
applyFilters();
|
||
});
|
||
|
||
// Seed threshold and min_gap from server config, then start
|
||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||
if (cfg.threshold != null)
|
||
document.getElementById('threshold-input').value = cfg.threshold;
|
||
if (cfg.min_gap != null)
|
||
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||
</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})')
|
||
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
|
||
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
||
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.")
|
||
|
||
prune_orphan_analyses(rec_dir.resolve())
|
||
|
||
class Handler(_Handler):
|
||
recordings_dir = str(rec_dir.resolve())
|
||
threshold = args.threshold
|
||
min_gap = args.min_gap
|
||
|
||
server = ThreadingHTTPServer((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 — 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:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("Stopped.")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|