feat: name cut clips by wall-clock time; fix recording filename format
Cut downloads were named by byte offsets (`..._cut_740s-750s.flac`). They are now named by the actual recording time the slice covers, e.g. `20260523_22-31-30_22-32-30.flac` for a 22:31:30->22:32:30 cut of a recording started at 22:00:00. To make this reliable, the recording filename is now a fixed `%Y%m%d_%H%M%S` start-time format (`FILENAME_FORMAT`) shared by isr.py and web.py, replacing the user-configurable `filename_pattern` (web.py never reads config.ini, so a custom pattern could not be parsed back). web.py parses the start time out of the filename via `_recording_start()` and builds cut names with `_cut_filename()`. The DATE column now also comes from the filename (falling back to mtime only for non-standard names), since mtime is the last write, not the start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
@@ -58,6 +58,12 @@ MIN_RMS = 0.002 # ≈ −54 dBFS; the floor never drops below this,
|
||||
|
||||
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
|
||||
|
||||
# Recording filenames encode the start time as this strftime format (kept in
|
||||
# sync with isr.FILENAME_FORMAT). It is the authoritative recording start and
|
||||
# the only reliable clock anchor — mtime drifts to the last write, so cut clip
|
||||
# names and the displayed date are both derived from this.
|
||||
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
|
||||
|
||||
MIME_TYPES = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
@@ -72,6 +78,35 @@ MIME_TYPES = {
|
||||
# Audio analysis helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _recording_start(stem: str):
|
||||
"""Parse the recording start time encoded in a filename stem.
|
||||
|
||||
Returns a datetime, or None if the stem is not in FILENAME_FORMAT
|
||||
(e.g. a manually renamed file). strptime ignores any extension because
|
||||
callers pass Path.stem.
|
||||
"""
|
||||
try:
|
||||
return datetime.strptime(stem, FILENAME_FORMAT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _cut_filename(stem: str, ext: str, start: float, end: float) -> str:
|
||||
"""Name a cut by the real wall-clock span it covers.
|
||||
|
||||
For a recording that started at 22:00:00, a 22:31:30→22:32:30 slice
|
||||
(start=1890, end=1950) becomes ``20260523_22-31-30_22-32-30.flac``.
|
||||
Falls back to the source stem plus second offsets when the filename is
|
||||
not in FILENAME_FORMAT (e.g. a manually renamed recording).
|
||||
"""
|
||||
started = _recording_start(stem)
|
||||
if started is None:
|
||||
return f'{stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
||||
cut_start = started + timedelta(seconds=start)
|
||||
cut_end = started + timedelta(seconds=end)
|
||||
return f'{cut_start:%Y%m%d}_{cut_start:%H-%M-%S}_{cut_end:%H-%M-%S}{ext}'
|
||||
|
||||
|
||||
def _live_wav_header(path: Path, size: int):
|
||||
"""Return the WAV header (through the 'data' chunk header) with RIFF and
|
||||
data sizes rewritten to match the current file size, or None.
|
||||
@@ -500,6 +535,11 @@ def list_files(recordings_dir: str):
|
||||
rel = str(path.relative_to(base)).replace('\\', '/')
|
||||
is_active = rel in active_files
|
||||
|
||||
# The recording start is encoded in the filename and is the true clock
|
||||
# anchor; mtime is only a fallback for files not in FILENAME_FORMAT.
|
||||
started = _recording_start(path.stem)
|
||||
date = (started or datetime.fromtimestamp(stat.st_mtime)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 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).
|
||||
@@ -509,7 +549,7 @@ def list_files(recordings_dir: str):
|
||||
'name': rel,
|
||||
'size': stat.st_size,
|
||||
'mtime': stat.st_mtime,
|
||||
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'date': date,
|
||||
'duration': duration,
|
||||
'ext': path.suffix.lower().lstrip('.'),
|
||||
'recording': is_active,
|
||||
@@ -831,7 +871,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
ext = path.suffix.lower()
|
||||
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
||||
out_name = _cut_filename(path.stem, ext, start, end)
|
||||
|
||||
# For lossless formats, re-encode (not copy) so the container header
|
||||
# is rewritten with the correct duration/size. For lossy formats,
|
||||
|
||||
Reference in New Issue
Block a user