Files
ISR/tests/test_web.py
T
admin 5e7620627b 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>
2026-06-11 14:30:30 +02:00

132 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the adaptive loud-section detector in web.py."""
import math
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_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_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"