Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user