Files
skills/docs/media/audio/audio-fingerprint/src/fingerprint.md
2026-05-29 08:20:56 +01:00

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, and n_chroma (defaults match the industry-standard 22050/2048/512/12).
  • librosa-backed audio loader with mono downmix, target-rate resampling, structured FileNotFoundError / RuntimeError failures.
  • 2D local-maximum peak detection on the normalised chromagram via scipy.ndimage.maximum_filter with 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_file that 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_peaks rather than extract_chromagram so 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_time lookup 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 case min_matches=0 with two empty inputs, preventing np.median([]) from raising.