"""Tests for the AudioFingerprinter chromagram + constellation pipeline.""" from __future__ import annotations import tempfile from pathlib import Path import numpy as np import pytest import soundfile as sf from src.fingerprint import AudioFingerprinter @pytest.fixture def fingerprinter() -> AudioFingerprinter: return AudioFingerprinter() def test_fingerprinter_initialization() -> None: fp = AudioFingerprinter() assert fp.sample_rate == 22050 assert fp.n_fft == 2048 assert fp.hop_length == 512 assert fp.n_chroma == 12 def test_fingerprinter_custom_parameters() -> None: fp = AudioFingerprinter( sample_rate=44100, n_fft=4096, hop_length=1024, n_chroma=24 ) assert fp.sample_rate == 44100 assert fp.n_fft == 4096 assert fp.hop_length == 1024 assert fp.n_chroma == 24 def test_load_audio(fingerprinter: AudioFingerprinter, audio_file: Path) -> None: audio = fingerprinter.load_audio(audio_file) assert isinstance(audio, np.ndarray) assert audio.dtype == np.float32 assert len(audio) > 0 assert -1.0 <= audio.min() <= 1.0 assert -1.0 <= audio.max() <= 1.0 def test_load_audio_nonexistent_file(fingerprinter: AudioFingerprinter) -> None: with pytest.raises(FileNotFoundError, match="Audio file not found"): fingerprinter.load_audio("/nonexistent/file.wav") def test_load_audio_invalid_data_raises_runtime_error( fingerprinter: AudioFingerprinter, ) -> None: """A file that exists but isn't decodable must surface as RuntimeError.""" with tempfile.NamedTemporaryFile( suffix=".wav", delete=False, mode="wb" ) as tmp: tmp.write(b"this is not a wav file at all") bad_path = Path(tmp.name) try: with pytest.raises(RuntimeError, match="Failed to load audio file"): fingerprinter.load_audio(bad_path) finally: bad_path.unlink(missing_ok=True) def test_extract_chromagram( fingerprinter: AudioFingerprinter, sample_audio: tuple[np.ndarray, int], ) -> None: audio, _ = sample_audio chromagram = fingerprinter.extract_chromagram(audio) assert isinstance(chromagram, np.ndarray) assert chromagram.shape[0] == fingerprinter.n_chroma assert chromagram.shape[1] > 0 assert chromagram.min() >= 0.0 def test_find_peaks( fingerprinter: AudioFingerprinter, sample_audio: tuple[np.ndarray, int], ) -> None: audio, _ = sample_audio chromagram = fingerprinter.extract_chromagram(audio) peaks = fingerprinter.find_peaks(chromagram, threshold=0.5) assert isinstance(peaks, list) assert len(peaks) > 0 for time, chroma, amplitude in peaks: assert isinstance(time, int) assert isinstance(chroma, int) assert isinstance(amplitude, float) assert 0 <= chroma < fingerprinter.n_chroma assert 0.0 <= amplitude <= 1.0 def test_find_peaks_threshold( fingerprinter: AudioFingerprinter, sample_audio: tuple[np.ndarray, int], ) -> None: audio, _ = sample_audio chromagram = fingerprinter.extract_chromagram(audio) peaks_low = fingerprinter.find_peaks(chromagram, threshold=0.3) peaks_high = fingerprinter.find_peaks(chromagram, threshold=0.7) assert len(peaks_low) >= len(peaks_high) def test_find_peaks_zero_magnitude(fingerprinter: AudioFingerprinter) -> None: """A zero chromagram must return an empty peak list.""" zero_chroma = np.zeros((fingerprinter.n_chroma, 50), dtype=np.float32) assert fingerprinter.find_peaks(zero_chroma) == [] def test_generate_fingerprint( fingerprinter: AudioFingerprinter, sample_audio: tuple[np.ndarray, int], ) -> None: audio, _ = sample_audio chromagram = fingerprinter.extract_chromagram(audio) peaks = fingerprinter.find_peaks(chromagram) fingerprints = fingerprinter.generate_fingerprint(peaks, fan_value=15) assert isinstance(fingerprints, set) assert len(fingerprints) > 0 for fp_hash, time, delta in fingerprints: assert isinstance(fp_hash, int) assert isinstance(time, int) assert isinstance(delta, int) assert time >= 0 assert delta >= 0 def test_generate_fingerprint_fan_value( fingerprinter: AudioFingerprinter, sample_audio: tuple[np.ndarray, int], ) -> None: audio, _ = sample_audio chromagram = fingerprinter.extract_chromagram(audio) peaks = fingerprinter.find_peaks(chromagram) fps_small = fingerprinter.generate_fingerprint(peaks, fan_value=5) fps_large = fingerprinter.generate_fingerprint(peaks, fan_value=20) assert len(fps_large) >= len(fps_small) def test_fingerprint_file( fingerprinter: AudioFingerprinter, audio_file: Path ) -> None: result = fingerprinter.fingerprint_file(audio_file) assert isinstance(result, dict) for key in ("fingerprints", "duration", "peaks_count", "fingerprints_count"): assert key in result assert isinstance(result["fingerprints"], set) assert len(result["fingerprints"]) > 0 assert result["duration"] > 0.0 assert result["peaks_count"] > 0 assert result["fingerprints_count"] == len(result["fingerprints"]) def test_match_fingerprints_identical( fingerprinter: AudioFingerprinter, audio_file: Path ) -> None: result1 = fingerprinter.fingerprint_file(audio_file) result2 = fingerprinter.fingerprint_file(audio_file) match = fingerprinter.match_fingerprints( result1["fingerprints"], result2["fingerprints"], min_matches=10 ) assert match["matched"] is True assert match["match_count"] > 0 assert match["confidence"] > 0.9 assert abs(match["time_offset"]) <= 5 def test_match_fingerprints_min_matches( fingerprinter: AudioFingerprinter, audio_file: Path ) -> None: result1 = fingerprinter.fingerprint_file(audio_file) result2 = fingerprinter.fingerprint_file(audio_file) match_reasonable = fingerprinter.match_fingerprints( result1["fingerprints"], result2["fingerprints"], min_matches=10 ) match_impossible = fingerprinter.match_fingerprints( result1["fingerprints"], result2["fingerprints"], min_matches=999999 ) assert match_reasonable["matched"] is True assert match_impossible["matched"] is False assert match_impossible["time_offset"] == 0 def test_match_fingerprints_empty(fingerprinter: AudioFingerprinter) -> None: """Empty inputs must yield zero match_count, zero confidence, zero offset.""" match = fingerprinter.match_fingerprints(set(), set(), min_matches=10) assert match["match_count"] == 0 assert match["confidence"] == 0 assert match["time_offset"] == 0 assert match["matched"] is False def test_match_fingerprints_with_offset( fingerprinter: AudioFingerprinter, ) -> None: """When matches exist, the median time offset between hashes is reported.""" query = {(1001, 0, 3), (1002, 10, 4), (1003, 20, 5)} reference = {(1001, 100, 3), (1002, 110, 4), (1003, 120, 5)} match = fingerprinter.match_fingerprints(query, reference, min_matches=2) assert match["matched"] is True assert match["match_count"] == 3 assert match["time_offset"] == 100 def test_match_fingerprints_partial_overlap( fingerprinter: AudioFingerprinter, ) -> None: """Query hashes not present in reference must be skipped without crashing.""" query = {(1001, 0, 3), (9999, 10, 4), (1003, 20, 5)} reference = {(1001, 50, 3), (1003, 70, 5), (4242, 999, 9)} match = fingerprinter.match_fingerprints(query, reference, min_matches=2) assert match["match_count"] == 2 assert match["matched"] is True # Offsets are 50 and 50 -> median 50. assert match["time_offset"] == 50 def test_match_fingerprints_zero_threshold_empty_inputs( fingerprinter: AudioFingerprinter, ) -> None: """min_matches=0 with empty inputs must not attempt median on empty list.""" match = fingerprinter.match_fingerprints(set(), set(), min_matches=0) assert match["match_count"] == 0 assert match["matched"] is True # 0 >= 0 assert match["time_offset"] == 0