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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user