first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# `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
```python
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
```python
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.