fix: detector rejects non-16-bit WAV and stops padding the last section

_compute_rms_windows_wav honours sampwidth for frame arithmetic but
decodes as int16 unconditionally, so an 8- or 24-bit WAV produced
meaningless RMS values and therefore meaningless sections, silently.
The recorder only writes 16-bit, but --dir points at arbitrary
directories. analyze_wav now refuses anything else, turning a silent
wrong answer into a visible one.

_loud_sections closed mid-file sections at last_loud_t + window_dur but
the trailing one at the file duration. A file whose last loud event is
followed by less than min_gap of quiet reported a section stretched by
up to min_gap seconds, and the clip served for it carried that silence.
Now capped at the last loud window, consistent with every other section
and with the score, which only ever covered the loud windows.

DETECTOR_VERSION 2 -> 3 so existing caches are recomputed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 20:30:46 +02:00
co-authored by Claude Opus 5
parent 9bb1229aa9
commit b661e166ed
2 changed files with 56 additions and 2 deletions
+41
View File
@@ -126,6 +126,27 @@ def test_burst_at_file_start_keeps_full_score():
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
@@ -197,6 +218,26 @@ def _write_wav(path, seconds=2, framerate=8000):
wf.writeframes(b'\x00\x00' * (seconds * framerate))
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):
# 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
+15 -2
View File
@@ -61,7 +61,8 @@ ONSET_SECONDS = 0.5 # a section's score is capped by its sharpest dB rise
# 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 = 2
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
@@ -388,8 +389,13 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
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(duration, 1),
sections.append({'start': round(start_t, 1),
'end': round(min(last_loud_t + window_dur, duration), 1),
'score': _score()})
return sections
@@ -421,6 +427,13 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
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: