feat: instant section playback via server-rendered clips

Add /api/clip: decodes a WAV/FLAC slice server-side and returns a small
standalone 16-bit WAV with exact Content-Length (capped at 600s, cached
client-side since finished recordings are immutable). Active recordings
are refused like analyse/cut/delete.

Section chips and J/K now play these clips through a bottom player bar
instead of seeking the full recording - FLACs have no seek table, so
browser seeks bisected hundreds of MB with Range requests and playback
lagged or never started. The bar steps through a queue (one file's
sections or a whole day's via Highlights), auto-advances to the next
section on end for continuous review, and "Open in file" jumps to the
same position in the full recording for context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:13:39 +02:00
parent c84b7d8222
commit 119e631faf
4 changed files with 224 additions and 24 deletions
+123
View File
@@ -55,6 +55,8 @@ NOISE_PERCENTILE = 20 # percentile of windowed dB levels taken as the floor
MIN_RMS = 0.002 # ≈ 54 dBFS; the floor never drops below this, so
# digital silence does not make every tiny sound loud
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
MIME_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
@@ -217,6 +219,17 @@ def _live_flac_header(path: Path, size: int):
return None
def _wav_header(n_frames: int, channels: int, framerate: int, sampwidth: int) -> bytes:
"""Standard 44-byte PCM WAV header for a clip of known length."""
data_len = n_frames * channels * sampwidth
byte_rate = framerate * channels * sampwidth
return (b'RIFF' + (36 + data_len).to_bytes(4, 'little') + b'WAVE'
+ b'fmt ' + (16).to_bytes(4, 'little')
+ struct.pack('<HHIIHH', 1, channels, framerate, byte_rate,
channels * sampwidth, sampwidth * 8)
+ b'data' + data_len.to_bytes(4, 'little'))
def _get_audio_duration(path: Path):
"""Return duration in seconds for any supported audio file, or None."""
ext = path.suffix.lower()
@@ -533,6 +546,8 @@ class _Handler(BaseHTTPRequestHandler):
self._api_config()
elif p == '/api/cut':
self._api_cut(qs)
elif p == '/api/clip':
self._api_clip(qs)
elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'):
@@ -830,6 +845,114 @@ class _Handler(BaseHTTPRequestHandler):
except Exception:
pass
def _api_clip(self, qs):
"""Serve a section of a WAV/FLAC file as a small standalone WAV.
Decoding the slice server-side means the browser plays a tiny file
instantly instead of seeking inside a multi-hundred-MB recording
(FLACs have no seek table, so browser seeks bisect the whole file
with Range requests)."""
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 = max(0.0, float(start_s))
end = float(end_s)
except (ValueError, TypeError):
self._json_err(400, 'start and end must be numbers (seconds)')
return
if end <= start:
self._json_err(400, 'end must be > start')
return
end = min(end, start + CLIP_MAX_SECONDS)
path = self._safe_path(filename)
if path is None:
return
if self._is_active(filename):
self._json_err(409, 'File is currently being recorded — clips unavailable until recording stops')
return
ext = path.suffix.lower()
try:
if ext == '.wav':
self._clip_wav(path, start, end)
elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC clips require: pip install numpy soundfile')
return
self._clip_flac(path, start, end)
else:
self._json_err(400, f'Clips are not available for {ext} files')
except (ConnectionError, BrokenPipeError):
raise
except Exception as e:
self._json_err(500, f'Clip failed: {e}')
def _send_clip_headers(self, header: bytes, n_frames: int, channels: int,
sampwidth: int):
self.send_response(200)
self.send_header('Content-Type', 'audio/wav')
self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth))
# Finished recordings are immutable, so stepping back to a section
# replays the clip from the browser cache
self.send_header('Cache-Control', 'private, max-age=86400')
self.end_headers()
self.wfile.write(header)
def _clip_wav(self, path: Path, start: float, end: float):
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
f0 = min(int(start * framerate), n_frames)
f1 = min(int(end * framerate), n_frames)
if f1 <= f0:
self._json_err(400, 'clip range is beyond the end of the file')
return
header = _wav_header(f1 - f0, channels, framerate, sampwidth)
self._send_clip_headers(header, f1 - f0, channels, sampwidth)
wf.setpos(f0)
remaining = f1 - f0
while remaining > 0:
chunk = wf.readframes(min(32768, remaining))
if not chunk:
self.close_connection = True # under-delivered
break
self.wfile.write(chunk)
remaining -= len(chunk) // (channels * sampwidth)
def _clip_flac(self, path: Path, start: float, end: float):
with sf.SoundFile(path) as f:
framerate = f.samplerate
channels = f.channels
n_frames = len(f)
f0 = min(int(start * framerate), n_frames)
f1 = min(int(end * framerate), n_frames)
if f1 <= f0:
self._json_err(400, 'clip range is beyond the end of the file')
return
header = _wav_header(f1 - f0, channels, framerate, 2)
self._send_clip_headers(header, f1 - f0, channels, 2)
f.seek(f0)
remaining = f1 - f0
while remaining > 0:
frames = f.read(min(32768, remaining), dtype='int16', always_2d=True)
if len(frames) == 0:
self.close_connection = True # under-delivered
break
self.wfile.write(frames.tobytes())
remaining -= len(frames)
def _is_active(self, filename: str) -> bool:
"""True if isr.py reports this file as currently being recorded."""
try: