5.2 KiB
fingerprint.py — AudioFingerprinter
Purpose
The AudioFingerprinter class is the Python core of @suaveplan/audio-fingerprint. It implements the full Shazam-style audio identification pipeline: load and downmix the source audio with librosa, run a Short-Time Fourier Transform, distil that magnitude spectrogram into a 12-bin chromagram (pitch-class profile), detect 2D local maxima in the chromagram with scipy.ndimage.maximum_filter, hash each peak with its fan_value nearest neighbours into a constellation map, and match two fingerprint sets using Jaccard similarity plus median time-offset estimation. It is the high-accuracy backend the React-tier @suaveplan/react-audio-fingerprint package is designed to call through the FastAPI bridge.
The pipeline is deliberately chromagram-based rather than raw-magnitude based. Chromagrams fold pitch into 12 bins, so the fingerprint is robust against octave shifts, compression artefacts, mild noise, EQ, and re-encoding — at the cost of being unable to distinguish two different pieces in the same key with the same rhythm. For corpus-scale broadcast monitoring or sample identification, this trade is the right one.
Features
- Configurable
sample_rate,n_fft,hop_length, andn_chroma(defaults match the industry-standard 22050/2048/512/12). - librosa-backed audio loader with mono downmix, target-rate resampling, structured
FileNotFoundError/RuntimeErrorfailures. - 2D local-maximum peak detection on the normalised chromagram via
scipy.ndimage.maximum_filterwith a(3, 5)neighbourhood (3 frequency bins by 5 time frames). - Constellation-map fingerprint hashing with a configurable
fan_value. - O(n+m) Jaccard matcher with median time-offset estimation, defensive against empty inputs and partial overlaps.
- Full pipeline orchestrator
fingerprint_filethat returns duration plus peak and fingerprint counts.
Usage: Basic
from src.fingerprint import AudioFingerprinter
fp = AudioFingerprinter()
result = fp.fingerprint_file("track.mp3", peak_threshold=0.5, fan_value=15)
print(result["duration"], result["peaks_count"], result["fingerprints_count"])
Usage: Advanced
from src.fingerprint import AudioFingerprinter
fp = AudioFingerprinter(sample_rate=22050, n_fft=2048, hop_length=512, n_chroma=12)
audio = fp.load_audio("query.wav")
chromagram = fp.extract_chromagram(audio) # shape (12, n_frames)
peaks = fp.find_peaks(chromagram, threshold=0.5)
query_fingerprints = fp.generate_fingerprint(peaks, fan_value=15)
reference = fp.fingerprint_file("reference.flac")
match = fp.match_fingerprints(query_fingerprints, reference["fingerprints"], min_matches=20)
print(match["matched"], match["confidence"], match["time_offset"])
API Reference
| Method | Signature | Description |
|---|---|---|
__init__ |
(sample_rate=22050, n_fft=2048, hop_length=512, n_chroma=12) |
Persists the analysis parameters as public attributes. |
load_audio |
(file_path: str | Path) -> NDArray[float32] |
Loads, downmixes to mono, resamples to sample_rate. Raises FileNotFoundError when the file is missing; raises RuntimeError("Failed to load audio file: ...") on librosa decoder failures. |
extract_chromagram |
(audio: NDArray) -> NDArray[float32] |
Computes the chromagram from the STFT magnitude. Output shape: (n_chroma, n_frames). |
find_peaks |
(chromagram: NDArray, threshold=0.5) -> list[tuple[int,int,float]] |
Detects 2D local maxima exceeding threshold on the normalised chromagram. Returns [] when the chromagram is all-zero. |
generate_fingerprint |
(peaks, fan_value=15) -> set[tuple[int,int,int]] |
Constellation-map hashing; each tuple is (hash, time_offset, time_delta). |
fingerprint_file |
(file_path, peak_threshold=0.5, fan_value=15) -> dict |
Runs the full pipeline. Returns fingerprints, duration, peaks_count, fingerprints_count. |
match_fingerprints |
(query, reference, min_matches=10) -> dict |
Returns matched, match_count, confidence (Jaccard), time_offset (median frame offset, 0 when no positive match). |
Implementation Notes
- The chromagram normalisation is done in
find_peaksrather thanextract_chromagramso the threshold semantics are stable across signals of differing absolute amplitude. - The
(3, 5)neighbourhood is intentionally asymmetric: time has more frames than there are pitch classes, so we accept a wider time window before considering a point a local maximum. - Constellation hashing uses Python's built-in
hash((f1, f2, dt)); the values are not cryptographically stable across interpreters but they are stable within a single run, which is all the matcher needs. - The matcher builds an O(m)
hash -> reference_timelookup table before iterating the query set; this turns the original upstream's nested O(n*m) loop into O(n + m) while preserving the median-of-offsets semantics. - An empty union of hashes is treated as a Jaccard denominator of
max(union_size, 1), so the empty/empty case returns confidence 0 cleanly rather than dividing by zero. - The
if match_count >= min_matches and matching_hashes:guard catches the edge casemin_matches=0with two empty inputs, preventingnp.median([])from raising.