Initial Commit
This commit is contained in:
229
packages/media/audio/audio-fingerprint/tests/test_fingerprint.py
Normal file
229
packages/media/audio/audio-fingerprint/tests/test_fingerprint.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user