diff --git a/tests/test_web.py b/tests/test_web.py index 0479a2d..aab04b5 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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 diff --git a/web.py b/web.py index 5eb89d6..d0b2fc1 100644 --- a/web.py +++ b/web.py @@ -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: