refactor: split web.py — detector and audio-format helpers into isrweb/

Pure move, no behaviour change. web.py 1221 -> 807 lines.

The reason is not file size but isolation of change. CLAUDE.md records
that the next step for detection, should flagging ever need improving, is
a spectral filter or an optional Silero VAD over candidate sections —
that work now lands entirely in isrweb/loudness.py and never has to open
the HTTP layer. The byte-level format code (FLAC frame parsing, CRC-8,
live header patching) is a second self-contained cluster, touched only
when a container format misbehaves.

  isrweb/loudness.py   229  detector + its tuning constants
  isrweb/audiofile.py  250  filename clock, WAV/FLAC headers, MIME types
  web.py               807  cache, listing, handler, main()

Import direction is strictly web.py -> {loudness, audiofile}; the two
leaves import neither each other nor web.py, which is what lets the
detector be imported and tested without an HTTP server.

A package rather than flat modules because `audiofile` is a real PyPI
name and `detect` is generic enough to collide; a local file silently
shadows an installed one, which fails confusingly rather than loudly.
web.py stays at the repo root so `python web.py`, the docker-compose
command and the webui.html lookup are all unchanged.

Each module keeps its own optional numpy/soundfile try-import rather than
sharing one. That block cannot meaningfully diverge — if numpy is
installed every copy says True — and sharing it would stop isr.py being a
standalone file you can drop on a Pi and run.

Tests mirror the modules: test_loudness.py, test_audiofile.py, test_web.py
(+ unchanged test_isr.py). New tests/conftest.py owns the sys.path setup
that was previously an inline hack in test_isr.py — which only worked for
the other modules because that file happened to import first — and
provides the write_wav fixture the split would otherwise have duplicated.

Verified:
* 78 passed, same count as before the split; each test file also passes
  standalone.
* All 21 moved functions diffed against HEAD:web.py — byte-identical.
* Import graph asserted acyclic; both leaves import with `web` absent
  from sys.modules.
* An analysis cache written *before* the split still hits afterwards,
  with identical sections — the one way a "pure" move could quietly
  invalidate every user's cache.
* Endpoint smoke (analyze bounds, cache hit, cached_analysis in the
  listing, clip Content-Length, 8-bit rejection) and the byte-serving
  harness (ranges, download Range, HEAD, 405, live-header patching whole
  and straddling) both re-run green.
* Dockerfile COPY set verified by building the image's file tree in a
  temp dir and importing web.py with nothing else present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 23:14:54 +02:00
co-authored by Claude Opus 5
parent 138f6db0a5
commit c91264c253
12 changed files with 766 additions and 663 deletions
+13 -5
View File
@@ -5,14 +5,16 @@ Guidance for Claude Code when working in this repository.
## Rules
- **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals.
- Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector).
- Run `python -m pytest tests/` after changing `isr.py`, `web.py` or `isrweb/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture.
## Files
| File | Purpose |
|------|---------|
| `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits |
| `web.py` | Archive browser: HTTP server, file listing, RMS loudness analysis, cut/delete |
| `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui.html` lookup work |
| `isrweb/loudness.py` | The adaptive loud-section detector. Pure computation, imports nothing from the app — future spectral/VAD work lands here and never touches HTTP |
| `isrweb/audiofile.py` | What an audio file is and what its name means: the filename clock, FLAC/WAV header parsing, live-header patching. Also a leaf |
| `webui.html` | Single-page UI (HTML/CSS/JS), loaded by `web.py` at startup — must sit next to `web.py` and be copied in the Dockerfile |
| `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) |
| `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard |
@@ -30,11 +32,17 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
## Code map
`web.py`:
**Import direction is strictly `web.py → {isrweb.loudness, isrweb.audiofile}`.** The two leaves never import each other or `web.py`; keep it that way, it is what lets the detector be tested without an HTTP server. Each module carries its own optional numpy/soundfile try-import — that boilerplate cannot meaningfully diverge, and sharing it would stop `isr.py` being a standalone file.
`isrweb/loudness.py`:
- Detection: `_compute_rms_windows_wav()` / `analyze_flac()` produce 100 ms RMS windows (at 48 kHz — `WINDOW_SAMPLES` is a fixed sample count, so windows are wider on lower-rate files; `analyze_wav()` rejects anything that is not 16-bit, which the RMS decoder assumes) → `_noise_floor_db()` estimates the rolling floor → `_loud_sections()` emits scored sections → `_package_result()` shapes the `/api/analyze` payload.
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice, `_wav_header()` builds the 44-byte PCM header.
`isrweb/audiofile.py`:
- Filenames as a clock: `_recording_start()` parses the start time out of a filename stem; `_cut_filename()` turns a (stem, ext, start, end) into a wall-clock-named cut. Both the listing `date` field and `_api_cut()` use them.
- Live headers: `_live_wav_header()`, `_live_flac_header()` (+ `_flac_frame_samples()`, CRC-8 verified).
- Live headers: `_live_wav_header()`, `_live_flac_header()` (+ `_flac_frame_samples()`, CRC-8 verified). Plus `_wav_header()` (44-byte PCM header for clips), `_get_audio_duration()`, `MIME_TYPES`, `AUDIO_EXTENSIONS`.
`web.py`:
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice.
- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard).
`webui.html` (one `<script>` block):
+1
View File
@@ -11,6 +11,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY isr.py web.py webui.html ./
COPY isrweb/ ./isrweb/
RUN mkdir -p /recordings
+1 -1
View File
@@ -149,7 +149,7 @@ python web.py --min-duration 1 # discard loud sections shorter than
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
```
The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at startup — keep the two files together.
The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at startup. `web.py` also imports the `isrweb/` package (the loud-section detector and the audio-format helpers) — keep `web.py`, `webui.html` and `isrweb/` together.
Shows recordings grouped by day with collapsible sections. Features:
+1
View File
@@ -0,0 +1 @@
"""Supporting modules for web.py (the archive browser)."""
+250
View File
@@ -0,0 +1,250 @@
"""What an audio file is, and what its name means.
Two clusters that share a theme — facts about the files on disk — and share
the property of being touched only when a container format misbehaves:
* the filename clock (recordings are named for their *start* time, and that
name is the only reliable time anchor the archive has), and
* byte-level format handling: the 44-byte PCM header for clips, duration
probing, and the live-header patching that lets a browser show a duration
for a file still being recorded.
Imports nothing from the rest of the app.
"""
import struct
import wave
from datetime import datetime, timedelta
from pathlib import Path
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
# 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',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.opus': 'audio/ogg',
}
# ---------------------------------------------------------------------------
# The filename clock
# ---------------------------------------------------------------------------
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.
While a WAV file is still being recorded its header claims ~0 frames, so
browsers show no duration and refuse to seek. Serving a header patched to
the bytes recorded so far fixes both; the patch is the same length as the
original header, so all byte offsets and Range math stay valid.
"""
try:
with open(path, 'rb') as fh:
hdr = fh.read(512)
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
return None
pos = 12
while pos + 8 <= len(hdr):
chunk_id = hdr[pos:pos + 4]
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
if chunk_id == b'data':
data_off = pos + 8
patched = bytearray(hdr[:data_off])
patched[4:8] = (size - 8).to_bytes(4, 'little')
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
return bytes(patched)
pos += 8 + chunk_size + (chunk_size & 1)
return None
except Exception:
return None
# CRC-8 (poly 0x07) used by FLAC frame headers
_CRC8_TABLE = []
for _i in range(256):
_c = _i
for _ in range(8):
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
_CRC8_TABLE.append(_c)
_FLAC_BLOCKSIZES = {1: 192, 2: 576, 3: 1152, 4: 2304, 5: 4608, 8: 256, 9: 512,
10: 1024, 11: 2048, 12: 4096, 13: 8192, 14: 16384, 15: 32768}
def _crc8(data: bytes) -> int:
crc = 0
for b in data:
crc = _CRC8_TABLE[crc ^ b]
return crc
def _flac_coded_number(buf: bytes, pos: int):
"""Decode the UTF-8-style frame/sample number; returns (value, next_pos)."""
b0 = buf[pos]
if b0 < 0x80:
return b0, pos + 1
n, mask = 0, 0x40
while b0 & mask:
n += 1
mask >>= 1
if n < 1 or n > 6: # 10xxxxxx is not a valid leading byte
return None
val = b0 & (mask - 1)
for i in range(1, n + 1):
c = buf[pos + i]
if c & 0xC0 != 0x80:
return None
val = (val << 6) | (c & 0x3F)
return val, pos + 1 + n
def _flac_frame_samples(buf: bytes, pos: int, fixed_bs: int):
"""If a valid FLAC frame header starts at pos, return the stream sample
count through the end of that frame, else None. Validity is confirmed by
the header's CRC-8, so false sync matches in compressed data are rejected."""
try:
variable = buf[pos + 1] & 0x01
bs_code = buf[pos + 2] >> 4
sr_code = buf[pos + 2] & 0x0F
if bs_code == 0 or sr_code == 15 or buf[pos + 3] & 0x01:
return None
if (buf[pos + 3] >> 4) > 10: # reserved channel assignment
return None
coded = _flac_coded_number(buf, pos + 4)
if coded is None:
return None
val, p = coded
bs = _FLAC_BLOCKSIZES.get(bs_code)
if bs_code == 6:
bs = buf[p] + 1
p += 1
elif bs_code == 7:
bs = int.from_bytes(buf[p:p + 2], 'big') + 1
p += 2
if sr_code == 12:
p += 1
elif sr_code in (13, 14):
p += 2
if _crc8(buf[pos:p]) != buf[p]:
return None
if variable: # val is the frame's starting sample number
return val + (bs or 0)
return val * (fixed_bs or bs or 4096) + (bs or 0)
except IndexError:
return None
def _live_flac_header(path: Path, size: int):
"""Return the first 26 bytes of a FLAC file with STREAMINFO total_samples
patched to the samples recorded so far, or None.
Like _live_wav_header, but FLAC duration cannot be derived from the byte
count (variable compression). Instead the sample count is parsed out of
the last frame header in the file tail — each FLAC frame carries its own
frame/sample number.
"""
try:
with open(path, 'rb') as fh:
head = fh.read(42)
if len(head) < 42 or head[:4] != b'fLaC':
return None
# STREAMINFO must be the first metadata block
if head[4] & 0x7F != 0 or int.from_bytes(head[5:8], 'big') != 34:
return None
fixed_bs = int.from_bytes(head[8:10], 'big')
tail_len = min(size, 65536)
fh.seek(size - tail_len)
buf = fh.read(tail_len)
samples = None
for i in range(len(buf) - 20, -1, -1):
if buf[i] == 0xFF and (buf[i + 1] & 0xFC) == 0xF8:
samples = _flac_frame_samples(buf, i, fixed_bs)
if samples:
break
if not samples:
return None
# Bytes 18-25 hold: sample rate (20 bits) | channels-1 (3) |
# bps-1 (5) | total_samples (36). Replace only the low 36 bits.
field = int.from_bytes(head[18:26], 'big')
field = (field & ~((1 << 36) - 1)) | min(samples, (1 << 36) - 1)
patched = bytearray(head[:26])
patched[18:26] = field.to_bytes(8, 'big')
return bytes(patched)
except Exception:
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()
if ext == '.wav':
try:
with wave.open(str(path), 'rb') as wf:
return round(wf.getnframes() / wf.getframerate(), 2)
except Exception:
return None
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
+229
View File
@@ -0,0 +1,229 @@
"""Adaptive loud-section detection.
Pure computation: RMS windows -> a rolling noise floor -> scored sections.
Nothing here knows about HTTP, caching or the archive on disk, and nothing
here imports the rest of the app — so the detector can be worked on, and
tested, on its own. That matters because this is the part most likely to
change: if flagging (rather than ranking) ever needs improving, the next
step is a spectral filter or an optional Silero VAD over candidate sections,
and it all lands in this file.
**Detection is adaptive — do not regress it to an absolute threshold.** A
fixed RMS threshold flagged every ambience change (passing cars, rain) and
produced ~600 useless sections a day; that is why it was replaced.
"""
import math
import struct
import wave
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
# ---------------------------------------------------------------------------
# Tuning
# ---------------------------------------------------------------------------
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
MIN_DURATION_SECONDS = 0.5 # discard loud sections shorter than this
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
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
ONSET_SECONDS = 0.5 # a section's score is capped by its sharpest dB rise
# within this span, so slow swells rank low
# Bumped whenever section detection/scoring changes in a way that makes old
# cached results wrong (not just differently parameterised). Caches written
# with another version never match and are recomputed on the next analyse.
DETECTOR_VERSION = 3 # v3: a section open at EOF ends at its last loud
# window, not at the file duration
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."""
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)
def _noise_floor_db(db_values: list, window_dur: float) -> list:
"""Per-window background noise floor in dBFS.
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
blocks so an event spanning a whole block cannot raise its own floor.
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
is relative to "how loud it normally is right now"."""
n = len(db_values)
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
floors = []
for i in range(0, n, block):
chunk = sorted(db_values[i:i + block])
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
def _loud_sections(rms_values: list, window_dur: float, duration: float,
margin_db: float, min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> list:
"""Sections whose level rises at least margin_db above the local noise
floor. Each section carries a 'score': its peak dB above the floor,
capped by the sharpest rise observed within ONSET_SECONDS. Real events
(voices, impacts, barks) have steep onsets, so their cap equals their
peak; a swell that drifts up slower than the noise-floor blocks can track
(wind, a distant approaching car) still flags but scores near zero, so
score-ranked review (chips, U/I highlights) surfaces events first.
Sections shorter than min_duration (after min_gap merging) are discarded:
without this, every isolated 100 ms window that pops above the floor — a
click, a single raindrop — becomes its own section and a day can drown in
thousands of sub-second clips."""
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
floor = _noise_floor_db(db, window_dur)
min_db = 20 * math.log10(MIN_RMS)
onset_win = max(1, int(round(ONSET_SECONDS / window_dur)))
sections = []
start_t = None
last_loud_t = None
peak = 0.0
onset = 0.0
def _score():
return round(max(0.0, min(peak, onset)), 1)
for i, d in enumerate(db):
t = i * window_dur
floor_eff = max(floor[i], min_db)
if d >= floor_eff + margin_db:
if start_t is None:
start_t = t
peak = 0.0
onset = 0.0
last_loud_t = t
peak = max(peak, d - floor_eff)
# Rise within the onset span; a section starting before the file
# has history is measured against the floor instead (an event cut
# off by a file split must not be punished as a swell).
rise = d - db[i - onset_win] if i >= onset_win else d - floor_eff
onset = max(onset, rise)
else:
if start_t is not None and (t - last_loud_t) > min_gap:
end_t = last_loud_t + window_dur
if end_t - start_t >= min_duration - 1e-9:
sections.append({'start': round(start_t, 1),
'end': round(end_t, 1),
'score': _score()})
start_t = None
last_loud_t = None
# A section still open at EOF ends at its last loud window, exactly like
# every section closed inside the loop — not at the file duration, which
# used to stretch it by up to min_gap of trailing silence and put that
# silence into the clip served for it.
if start_t is not None and (last_loud_t + window_dur - start_t) >= min_duration - 1e-9:
sections.append({'start': round(start_t, 1),
'end': round(min(last_loud_t + window_dur, duration), 1),
'score': _score()})
return sections
def _package_result(rms_values: list, framerate: int, n_frames: int,
window_samples: int, margin_db: float,
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
# Note: no RMS data is returned — the UI is screen-reader oriented and
# draws no waveform, so sections + duration is all it needs. The window
# length went the same way: it only ever existed to place RMS bars.
return {
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
'duration': round(duration, 2),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
# _compute_rms_windows_wav decodes as int16 unconditionally, so an
# 8- or 24-bit file would yield garbage RMS and therefore garbage
# sections — silently. The recorder only writes 16-bit, but --dir
# can point at any directory.
if sampwidth != 2:
return {'error': f'only 16-bit WAV files can be analysed '
f'(this one is {sampwidth * 8}-bit)'}
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, margin_db, min_gap, min_duration)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_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, margin_db, min_gap, min_duration)
+30
View File
@@ -0,0 +1,30 @@
"""Shared test setup.
Puts the repo root on sys.path so `import isr`, `import web` and
`import isrweb.*` work regardless of where pytest is invoked from. This used
to be an inline sys.path.insert at the top of test_isr.py, which only
happened to work for the other test modules because that file imported first.
"""
import os
import sys
import wave
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture
def write_wav():
"""Factory for small 16-bit WAV files. Returns the path written."""
def _write(path, seconds=2, framerate=8000, channels=1, sampwidth=2,
frames=None):
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(channels)
wf.setsampwidth(sampwidth)
wf.setframerate(framerate)
wf.writeframes(frames if frames is not None
else b'\x00\x00' * (seconds * framerate * channels))
return path
return _write
+35
View File
@@ -0,0 +1,35 @@
"""Tests for the filename clock in isrweb/audiofile.py.
Recordings are named for their *start* time, and that name is the only
reliable time anchor the archive has — mtime drifts to the last write.
"""
from isrweb.audiofile import _cut_filename, _recording_start
def test_recording_start_parses_standard_name():
from datetime import datetime
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
def test_recording_start_rejects_nonstandard_name():
assert _recording_start("radio1_20260523") is None
assert _recording_start("notes") is None
def test_cut_filename_uses_wall_clock_span():
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
assert name == "20260523_22-31-30_22-32-30.flac"
def test_cut_filename_rolls_over_the_hour():
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
assert name == "20260523_22-59-50_23-01-01.wav"
def test_cut_filename_falls_back_for_nonstandard_name():
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
assert name == "mixtape_cut_740s-750s.mp3"
+2 -6
View File
@@ -31,14 +31,10 @@ def fixed_clock(dt: datetime):
# ---------------------------------------------------------------------------
# Import the module under test
# Import the module under test (sys.path is set up in conftest.py)
# ---------------------------------------------------------------------------
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import isr # noqa: E402 (must come after sys.path manipulation)
import isr
# ===========================================================================
+176
View File
@@ -0,0 +1,176 @@
"""Tests for the adaptive loud-section detector in isrweb/loudness.py."""
import math
import wave
from isrweb.loudness import _loud_sections, _noise_floor_db, analyze_wav
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
def _run(rms, margin_db=12.0, min_gap=2.0, min_duration=0.5):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
def test_burst_above_quiet_floor_is_detected():
rms = [0.002] * 1200 # 2 min of quiet ambience (−54 dBFS)
rms[600:610] = [0.05] * 10 # 1 s burst at −26 dBFS (+28 dB)
sections = _run(rms)
assert len(sections) == 1
s = sections[0]
assert s['start'] == 60.0
assert 60.5 <= s['end'] <= 62.0
assert 26.0 <= s['score'] <= 30.0
def test_slow_ambience_swell_is_not_detected():
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
# The old fixed threshold would have flagged the entire loud half.
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # −54 → −34 dB
down = list(reversed(up))
assert _run(up + down) == []
def test_burst_still_detected_on_loud_ambience():
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
rms = [0.02] * 1200
rms[600:610] = [0.5] * 10
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_rms_floor_suppresses_blips_in_digital_silence():
rms = [0.000001] * 1200
rms[600:605] = [0.004] * 5 # −48 dBFS: audible blip, but below MIN_RMS+12dB
assert _run(rms) == []
rms[600:605] = [0.05] * 5 # −26 dBFS clearly clears the clamped floor
assert len(_run(rms)) == 1
def test_min_gap_merges_nearby_bursts():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
rms[900:905] = [0.05] * 5 # 28 s away → separate section
sections = _run(rms)
assert len(sections) == 2
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.5
assert sections[1]['start'] == 90.0
def test_min_duration_drops_subsecond_blips():
# Isolated single-window pops (clicks, single raindrops) spaced wider than
# min_gap must not each become their own section — this is what used to
# produce thousands of zero-length sections per day.
rms = [0.002] * 1200
for i in range(600, 660, 30): # 0.1 s blips, 3 s apart (> min_gap)
rms[i] = 0.05
assert _run(rms) == []
# With the filter disabled they are all reported
assert len(_run(rms, min_duration=0.0)) == 2
def test_min_duration_keeps_sections_at_or_above_it():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5 # exactly 0.5 s
sections = _run(rms, min_duration=0.5)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_duration_applies_after_gap_merging():
# Two sub-min_duration blips within min_gap merge into one section whose
# loud span exceeds min_duration — the merged section must survive.
rms = [0.002] * 1200
rms[600] = 0.05
rms[610] = 0.05 # 1 s apart < 2 s min_gap → merged, 1.1 s span
sections = _run(rms, min_duration=1.0)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.0
def test_slow_swell_scores_near_zero_sharp_burst_scores_high():
# A ~10 s swell rises faster than the 30 s floor blocks can track, so it
# still flags — but its score must collapse (onset cap) so it ranks at the
# bottom, while a sharp burst with the same peak keeps its full score.
rms = [0.002] * 1200
for k in range(80): # rise −54 → −28 dB over 8 s
rms[400 + k] = 0.002 * 10 ** (26 * (k / 80) / 20)
rms[480:500] = [0.04] * 20 # hold 2 s at −28 dB
for k in range(80): # fall back over 8 s
rms[500 + k] = 0.04 * 10 ** (-26 * (k / 80) / 20)
rms[900:910] = [0.04] * 10 # sharp 1 s burst, same peak
sections = _run(rms)
assert len(sections) == 2
swell, burst = sections
assert swell['start'] < 50.0 < 90.0 <= burst['start']
assert burst['score'] >= 24.0 # ≈ full 26 dB prominence
assert swell['score'] <= 5.0 # capped by its slow onset
assert swell['score'] < burst['score']
def test_burst_at_file_start_keeps_full_score():
# No pre-event history to measure a rise against: the onset cap falls back
# to prominence above the floor, so events cut off by a file split are not
# punished as swells.
rms = [0.05] * 10 + [0.002] * 1190
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 0.0
assert sections[0]['score'] >= 24.0
def test_section_open_at_eof_ends_at_last_loud_window():
# Burst ends 1 s before EOF — less than min_gap, so the section is still
# open when the windows run out. It must end where the sound ended, not at
# the file duration; otherwise the clip carries a second of trailing
# silence and disagrees with every section closed mid-file.
rms = [0.002] * 1200
rms[1180:1190] = [0.05] * 10 # loud 118.0 → 119.0, file is 120.0 s
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 118.0
assert sections[0]['end'] == 119.0
def test_section_running_to_the_very_end_is_capped_at_duration():
rms = [0.002] * 1200
rms[1190:1200] = [0.05] * 10 # loud right up to EOF
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['end'] == 120.0
def test_noise_floor_tracks_blocks_and_ignores_short_events():
quiet_db = 20 * math.log10(0.002)
db = [quiet_db] * 1200
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
floor = _noise_floor_db(db, WINDOW_DUR)
assert len(floor) == len(db)
assert all(abs(f - quiet_db) < 1.0 for f in floor)
def test_non_16_bit_wav_is_rejected_rather_than_misread(tmp_path, write_wav):
# RMS windowing decodes as int16 regardless of the declared sample width,
# so an 8-bit file used to produce meaningless sections with no warning.
path = tmp_path / 'eight_bit.wav'
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(1)
wf.setframerate(8000)
wf.writeframes(b'\x80' * 8000)
result = analyze_wav(path)
assert 'error' in result
assert '16-bit' in result['error']
def test_16_bit_wav_still_analyses(tmp_path, write_wav):
path = tmp_path / 'ok.wav'
write_wav(path)
assert 'error' not in analyze_wav(path)
+15 -224
View File
@@ -1,189 +1,21 @@
"""Tests for the adaptive loud-section detector in web.py."""
"""Tests for the HTTP layer and file listing in web.py."""
import io
import math
import wave
import web
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
def _run(rms, margin_db=12.0, min_gap=2.0, min_duration=0.5):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
def test_listing_is_ordered_by_the_filename_clock_not_mtime(tmp_path, write_wav):
# mtime is the last write (≈ the end of the recording) and drifts from the
# name on any copy or round-trip. Here the older recording is touched last.
import os
early, late = tmp_path / '20260523_080000.wav', tmp_path / '20260523_090000.wav'
write_wav(early)
write_wav(late)
os.utime(late, (1000, 1000)) # newest recording, oldest mtime
def test_burst_above_quiet_floor_is_detected():
rms = [0.002] * 1200 # 2 min of quiet ambience (−54 dBFS)
rms[600:610] = [0.05] * 10 # 1 s burst at −26 dBFS (+28 dB)
sections = _run(rms)
assert len(sections) == 1
s = sections[0]
assert s['start'] == 60.0
assert 60.5 <= s['end'] <= 62.0
assert 26.0 <= s['score'] <= 30.0
def test_slow_ambience_swell_is_not_detected():
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
# The old fixed threshold would have flagged the entire loud half.
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # −54 → −34 dB
down = list(reversed(up))
assert _run(up + down) == []
def test_burst_still_detected_on_loud_ambience():
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
rms = [0.02] * 1200
rms[600:610] = [0.5] * 10
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_rms_floor_suppresses_blips_in_digital_silence():
rms = [0.000001] * 1200
rms[600:605] = [0.004] * 5 # −48 dBFS: audible blip, but below MIN_RMS+12dB
assert _run(rms) == []
rms[600:605] = [0.05] * 5 # −26 dBFS clearly clears the clamped floor
assert len(_run(rms)) == 1
def test_min_gap_merges_nearby_bursts():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
rms[900:905] = [0.05] * 5 # 28 s away → separate section
sections = _run(rms)
assert len(sections) == 2
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.5
assert sections[1]['start'] == 90.0
def test_min_duration_drops_subsecond_blips():
# Isolated single-window pops (clicks, single raindrops) spaced wider than
# min_gap must not each become their own section — this is what used to
# produce thousands of zero-length sections per day.
rms = [0.002] * 1200
for i in range(600, 660, 30): # 0.1 s blips, 3 s apart (> min_gap)
rms[i] = 0.05
assert _run(rms) == []
# With the filter disabled they are all reported
assert len(_run(rms, min_duration=0.0)) == 2
def test_min_duration_keeps_sections_at_or_above_it():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5 # exactly 0.5 s
sections = _run(rms, min_duration=0.5)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_duration_applies_after_gap_merging():
# Two sub-min_duration blips within min_gap merge into one section whose
# loud span exceeds min_duration — the merged section must survive.
rms = [0.002] * 1200
rms[600] = 0.05
rms[610] = 0.05 # 1 s apart < 2 s min_gap → merged, 1.1 s span
sections = _run(rms, min_duration=1.0)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.0
def test_slow_swell_scores_near_zero_sharp_burst_scores_high():
# A ~10 s swell rises faster than the 30 s floor blocks can track, so it
# still flags — but its score must collapse (onset cap) so it ranks at the
# bottom, while a sharp burst with the same peak keeps its full score.
rms = [0.002] * 1200
for k in range(80): # rise −54 → −28 dB over 8 s
rms[400 + k] = 0.002 * 10 ** (26 * (k / 80) / 20)
rms[480:500] = [0.04] * 20 # hold 2 s at −28 dB
for k in range(80): # fall back over 8 s
rms[500 + k] = 0.04 * 10 ** (-26 * (k / 80) / 20)
rms[900:910] = [0.04] * 10 # sharp 1 s burst, same peak
sections = _run(rms)
assert len(sections) == 2
swell, burst = sections
assert swell['start'] < 50.0 < 90.0 <= burst['start']
assert burst['score'] >= 24.0 # ≈ full 26 dB prominence
assert swell['score'] <= 5.0 # capped by its slow onset
assert swell['score'] < burst['score']
def test_burst_at_file_start_keeps_full_score():
# No pre-event history to measure a rise against: the onset cap falls back
# to prominence above the floor, so events cut off by a file split are not
# punished as swells.
rms = [0.05] * 10 + [0.002] * 1190
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 0.0
assert sections[0]['score'] >= 24.0
def test_section_open_at_eof_ends_at_last_loud_window():
# Burst ends 1 s before EOF — less than min_gap, so the section is still
# open when the windows run out. It must end where the sound ended, not at
# the file duration; otherwise the clip carries a second of trailing
# silence and disagrees with every section closed mid-file.
rms = [0.002] * 1200
rms[1180:1190] = [0.05] * 10 # loud 118.0 → 119.0, file is 120.0 s
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 118.0
assert sections[0]['end'] == 119.0
def test_section_running_to_the_very_end_is_capped_at_duration():
rms = [0.002] * 1200
rms[1190:1200] = [0.05] * 10 # loud right up to EOF
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['end'] == 120.0
def test_noise_floor_tracks_blocks_and_ignores_short_events():
quiet_db = 20 * math.log10(0.002)
db = [quiet_db] * 1200
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
floor = _noise_floor_db(db, WINDOW_DUR)
assert len(floor) == len(db)
assert all(abs(f - quiet_db) < 1.0 for f in floor)
# ---------------------------------------------------------------------------
# Filename parsing / cut naming
# ---------------------------------------------------------------------------
def test_recording_start_parses_standard_name():
from datetime import datetime
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
def test_recording_start_rejects_nonstandard_name():
assert _recording_start("radio1_20260523") is None
assert _recording_start("notes") is None
def test_cut_filename_uses_wall_clock_span():
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
assert name == "20260523_22-31-30_22-32-30.flac"
def test_cut_filename_rolls_over_the_hour():
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
assert name == "20260523_22-59-50_23-01-01.wav"
def test_cut_filename_falls_back_for_nonstandard_name():
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
assert name == "mixtape_cut_740s-750s.mp3"
names = [f['name'] for f in web.list_files(str(tmp_path))]
assert names == ['20260523_090000.wav', '20260523_080000.wav']
# ---------------------------------------------------------------------------
@@ -211,52 +43,11 @@ class _OfflineHandler(web._Handler):
pass
def _write_wav(path, seconds=2, framerate=8000):
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(framerate)
wf.writeframes(b'\x00\x00' * (seconds * framerate))
def test_listing_is_ordered_by_the_filename_clock_not_mtime(tmp_path):
# mtime is the last write (≈ the end of the recording) and drifts from the
# name on any copy or round-trip. Here the older recording is touched last.
import os
early, late = tmp_path / '20260523_080000.wav', tmp_path / '20260523_090000.wav'
_write_wav(early)
_write_wav(late)
os.utime(late, (1000, 1000)) # newest recording, oldest mtime
names = [f['name'] for f in web.list_files(str(tmp_path))]
assert names == ['20260523_090000.wav', '20260523_080000.wav']
def test_non_16_bit_wav_is_rejected_rather_than_misread(tmp_path):
# RMS windowing decodes as int16 regardless of the declared sample width,
# so an 8-bit file used to produce meaningless sections with no warning.
path = tmp_path / 'eight_bit.wav'
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(1)
wf.setframerate(8000)
wf.writeframes(b'\x80' * 8000)
result = web.analyze_wav(path)
assert 'error' in result
assert '16-bit' in result['error']
def test_16_bit_wav_still_analyses(tmp_path):
path = tmp_path / 'ok.wav'
_write_wav(path)
assert 'error' not in web.analyze_wav(path)
def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path):
def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path, write_wav):
# Once the audio headers are out, an error body would be a second response
# on a keep-alive connection that already promised audio/wav plus an exact
# Content-Length — desyncing every later request on that connection.
_write_wav(tmp_path / 'rec.wav')
write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
@@ -271,8 +62,8 @@ def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tm
assert b'error' not in h.wfile.getvalue() # no JSON body
def test_clip_failure_before_headers_still_reports_json(tmp_path):
_write_wav(tmp_path / 'rec.wav')
def test_clip_failure_before_headers_still_reports_json(tmp_path, write_wav):
write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
+13 -427
View File
@@ -15,24 +15,22 @@ Usage:
import argparse
import json
import math
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import threading
import wave
from datetime import datetime, timedelta
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
try:
import numpy as np
NUMPY_AVAILABLE = True
import numpy # noqa: F401 — only the availability flag is used here,
NUMPY_AVAILABLE = True # to refuse FLAC work rather than fail halfway
except ImportError:
NUMPY_AVAILABLE = False
@@ -42,434 +40,22 @@ try:
except ImportError:
SOUNDFILE_AVAILABLE = False
# The detector and the byte-level format helpers live beside this file. Both
# are leaves: they import nothing from here, so `web.py -> {loudness,
# audiofile}` is the only import edge and there are no cycles.
from isrweb.audiofile import (AUDIO_EXTENSIONS, MIME_TYPES, _cut_filename,
_get_audio_duration, _live_flac_header,
_live_wav_header, _recording_start, _wav_header)
from isrweb.loudness import (DETECTOR_VERSION, MARGIN_DB, MIN_DURATION_SECONDS,
MIN_GAP_SECONDS, analyze_flac, analyze_wav)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
MIN_DURATION_SECONDS = 0.5 # discard loud sections shorter than this
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
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
ONSET_SECONDS = 0.5 # a section's score is capped by its sharpest dB rise
# within this span, so slow swells rank low
# Bumped whenever section detection/scoring changes in a way that makes old
# cached results wrong (not just differently parameterised). Caches written
# with another version never match and are recomputed on the next analyse.
DETECTOR_VERSION = 3 # v3: a section open at EOF ends at its last loud
# window, not at the file duration
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',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.opus': 'audio/ogg',
}
# ---------------------------------------------------------------------------
# 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.
While a WAV file is still being recorded its header claims ~0 frames, so
browsers show no duration and refuse to seek. Serving a header patched to
the bytes recorded so far fixes both; the patch is the same length as the
original header, so all byte offsets and Range math stay valid.
"""
try:
with open(path, 'rb') as fh:
hdr = fh.read(512)
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
return None
pos = 12
while pos + 8 <= len(hdr):
chunk_id = hdr[pos:pos + 4]
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
if chunk_id == b'data':
data_off = pos + 8
patched = bytearray(hdr[:data_off])
patched[4:8] = (size - 8).to_bytes(4, 'little')
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
return bytes(patched)
pos += 8 + chunk_size + (chunk_size & 1)
return None
except Exception:
return None
# CRC-8 (poly 0x07) used by FLAC frame headers
_CRC8_TABLE = []
for _i in range(256):
_c = _i
for _ in range(8):
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
_CRC8_TABLE.append(_c)
_FLAC_BLOCKSIZES = {1: 192, 2: 576, 3: 1152, 4: 2304, 5: 4608, 8: 256, 9: 512,
10: 1024, 11: 2048, 12: 4096, 13: 8192, 14: 16384, 15: 32768}
def _crc8(data: bytes) -> int:
crc = 0
for b in data:
crc = _CRC8_TABLE[crc ^ b]
return crc
def _flac_coded_number(buf: bytes, pos: int):
"""Decode the UTF-8-style frame/sample number; returns (value, next_pos)."""
b0 = buf[pos]
if b0 < 0x80:
return b0, pos + 1
n, mask = 0, 0x40
while b0 & mask:
n += 1
mask >>= 1
if n < 1 or n > 6: # 10xxxxxx is not a valid leading byte
return None
val = b0 & (mask - 1)
for i in range(1, n + 1):
c = buf[pos + i]
if c & 0xC0 != 0x80:
return None
val = (val << 6) | (c & 0x3F)
return val, pos + 1 + n
def _flac_frame_samples(buf: bytes, pos: int, fixed_bs: int):
"""If a valid FLAC frame header starts at pos, return the stream sample
count through the end of that frame, else None. Validity is confirmed by
the header's CRC-8, so false sync matches in compressed data are rejected."""
try:
variable = buf[pos + 1] & 0x01
bs_code = buf[pos + 2] >> 4
sr_code = buf[pos + 2] & 0x0F
if bs_code == 0 or sr_code == 15 or buf[pos + 3] & 0x01:
return None
if (buf[pos + 3] >> 4) > 10: # reserved channel assignment
return None
coded = _flac_coded_number(buf, pos + 4)
if coded is None:
return None
val, p = coded
bs = _FLAC_BLOCKSIZES.get(bs_code)
if bs_code == 6:
bs = buf[p] + 1
p += 1
elif bs_code == 7:
bs = int.from_bytes(buf[p:p + 2], 'big') + 1
p += 2
if sr_code == 12:
p += 1
elif sr_code in (13, 14):
p += 2
if _crc8(buf[pos:p]) != buf[p]:
return None
if variable: # val is the frame's starting sample number
return val + (bs or 0)
return val * (fixed_bs or bs or 4096) + (bs or 0)
except IndexError:
return None
def _live_flac_header(path: Path, size: int):
"""Return the first 26 bytes of a FLAC file with STREAMINFO total_samples
patched to the samples recorded so far, or None.
Like _live_wav_header, but FLAC duration cannot be derived from the byte
count (variable compression). Instead the sample count is parsed out of
the last frame header in the file tail — each FLAC frame carries its own
frame/sample number.
"""
try:
with open(path, 'rb') as fh:
head = fh.read(42)
if len(head) < 42 or head[:4] != b'fLaC':
return None
# STREAMINFO must be the first metadata block
if head[4] & 0x7F != 0 or int.from_bytes(head[5:8], 'big') != 34:
return None
fixed_bs = int.from_bytes(head[8:10], 'big')
tail_len = min(size, 65536)
fh.seek(size - tail_len)
buf = fh.read(tail_len)
samples = None
for i in range(len(buf) - 20, -1, -1):
if buf[i] == 0xFF and (buf[i + 1] & 0xFC) == 0xF8:
samples = _flac_frame_samples(buf, i, fixed_bs)
if samples:
break
if not samples:
return None
# Bytes 18-25 hold: sample rate (20 bits) | channels-1 (3) |
# bps-1 (5) | total_samples (36). Replace only the low 36 bits.
field = int.from_bytes(head[18:26], 'big')
field = (field & ~((1 << 36) - 1)) | min(samples, (1 << 36) - 1)
patched = bytearray(head[:26])
patched[18:26] = field.to_bytes(8, 'big')
return bytes(patched)
except Exception:
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()
if ext == '.wav':
try:
with wave.open(str(path), 'rb') as wf:
return round(wf.getnframes() / wf.getframerate(), 2)
except Exception:
return None
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."""
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)
def _noise_floor_db(db_values: list, window_dur: float) -> list:
"""Per-window background noise floor in dBFS.
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
blocks so an event spanning a whole block cannot raise its own floor.
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
is relative to "how loud it normally is right now"."""
n = len(db_values)
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
floors = []
for i in range(0, n, block):
chunk = sorted(db_values[i:i + block])
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
def _loud_sections(rms_values: list, window_dur: float, duration: float,
margin_db: float, min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> list:
"""Sections whose level rises at least margin_db above the local noise
floor. Each section carries a 'score': its peak dB above the floor,
capped by the sharpest rise observed within ONSET_SECONDS. Real events
(voices, impacts, barks) have steep onsets, so their cap equals their
peak; a swell that drifts up slower than the noise-floor blocks can track
(wind, a distant approaching car) still flags but scores near zero, so
score-ranked review (chips, U/I highlights) surfaces events first.
Sections shorter than min_duration (after min_gap merging) are discarded:
without this, every isolated 100 ms window that pops above the floor — a
click, a single raindrop — becomes its own section and a day can drown in
thousands of sub-second clips."""
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
floor = _noise_floor_db(db, window_dur)
min_db = 20 * math.log10(MIN_RMS)
onset_win = max(1, int(round(ONSET_SECONDS / window_dur)))
sections = []
start_t = None
last_loud_t = None
peak = 0.0
onset = 0.0
def _score():
return round(max(0.0, min(peak, onset)), 1)
for i, d in enumerate(db):
t = i * window_dur
floor_eff = max(floor[i], min_db)
if d >= floor_eff + margin_db:
if start_t is None:
start_t = t
peak = 0.0
onset = 0.0
last_loud_t = t
peak = max(peak, d - floor_eff)
# Rise within the onset span; a section starting before the file
# has history is measured against the floor instead (an event cut
# off by a file split must not be punished as a swell).
rise = d - db[i - onset_win] if i >= onset_win else d - floor_eff
onset = max(onset, rise)
else:
if start_t is not None and (t - last_loud_t) > min_gap:
end_t = last_loud_t + window_dur
if end_t - start_t >= min_duration - 1e-9:
sections.append({'start': round(start_t, 1),
'end': round(end_t, 1),
'score': _score()})
start_t = None
last_loud_t = None
# A section still open at EOF ends at its last loud window, exactly like
# every section closed inside the loop — not at the file duration, which
# used to stretch it by up to min_gap of trailing silence and put that
# silence into the clip served for it.
if start_t is not None and (last_loud_t + window_dur - start_t) >= min_duration - 1e-9:
sections.append({'start': round(start_t, 1),
'end': round(min(last_loud_t + window_dur, duration), 1),
'score': _score()})
return sections
def _package_result(rms_values: list, framerate: int, n_frames: int,
window_samples: int, margin_db: float,
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
# Note: no RMS data is returned — the UI is screen-reader oriented and
# draws no waveform, so sections + duration is all it needs. The window
# length went the same way: it only ever existed to place RMS bars.
return {
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
'duration': round(duration, 2),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
# _compute_rms_windows_wav decodes as int16 unconditionally, so an
# 8- or 24-bit file would yield garbage RMS and therefore garbage
# sections — silently. The recorder only writes 16-bit, but --dir
# can point at any directory.
if sampwidth != 2:
return {'error': f'only 16-bit WAV files can be analysed '
f'(this one is {sampwidth * 8}-bit)'}
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, margin_db, min_gap, min_duration)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_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, margin_db, min_gap, min_duration)
# ---------------------------------------------------------------------------
# Analysis cache helpers