Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""Suaveplan audio-fingerprint Python package."""
from .fingerprint import AudioFingerprinter
__all__ = ["AudioFingerprinter"]

View File

@@ -0,0 +1,5 @@
"""FastAPI app for the audio-fingerprinting service."""
from .app import app
__all__ = ["app"]

View File

@@ -0,0 +1,83 @@
# `api/app.py` — FastAPI service
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`api/app.py` wraps the `AudioFingerprinter` core into a FastAPI HTTP service. It is the network surface the TypeScript `PythonAudioService` client talks to from browsers (via the React-tier sibling) and from server-side JavaScript runtimes. The application exposes a health-check endpoint and two operational endpoints (`POST /fingerprint`, `POST /match`) that accept multipart file uploads and return JSON. It is intentionally a thin REST shell: every business decision (chromagram parameters, peak threshold, fan value, min matches) is driven by client-supplied form fields.
The service is deployment-tier code — it runs under `uvicorn` and is intended to live behind a reverse proxy in production. CORS is pre-configured for the two local development origins (`http://localhost:3000` and `http://localhost:5173`); add production origins via your deployment-time `CORSMiddleware` override.
## Features
- `GET /` health-check returning `{"status": "ok", "service": "audio-fingerprinting"}`.
- `POST /fingerprint` accepting an audio file, optional `peak_threshold` and `fan_value`, returning fingerprint hashes and statistics.
- `POST /match` accepting two audio files, optional `peak_threshold`, `fan_value`, `min_matches`, returning the match decision, count, confidence, and time offset.
- Temporary-file lifecycle: each upload is persisted to `NamedTemporaryFile`, processed, and unlinked in a `finally` block — including on the failure path.
- Uniform HTTP-500 error envelope: pipeline exceptions are caught and re-raised as `HTTPException(status_code=500, detail="Failed to ... : <message>")`.
- CORS pre-configured for `localhost:3000` and `localhost:5173`.
## Usage: Basic
```bash
# Start the service
micromamba run -n suaveplan-audio-fingerprint \
uvicorn src.api.app:app --host 0.0.0.0 --port 8000
# Health check
curl http://localhost:8000/
# Fingerprint a single file
curl -X POST http://localhost:8000/fingerprint -F "file=@track.mp3"
```
## Usage: Advanced
```bash
# Tune the algorithm parameters at request time
curl -X POST http://localhost:8000/fingerprint \
-F "file=@track.flac" \
-F "peak_threshold=0.6" \
-F "fan_value=20"
# Match two files with a stricter min_matches threshold
curl -X POST http://localhost:8000/match \
-F "query_file=@query.wav" \
-F "reference_file=@reference.wav" \
-F "peak_threshold=0.5" \
-F "fan_value=15" \
-F "min_matches=25"
```
```python
# Programmatic in-process testing via httpx ASGITransport
from httpx import ASGITransport, AsyncClient
from src.api.app import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.get("/")
assert response.json() == {"status": "ok", "service": "audio-fingerprinting"}
```
## API Reference
| Method | Path | Form fields | Response |
|---|---|---|---|
| `GET` | `/` | — | `{"status": "ok", "service": "audio-fingerprinting"}` |
| `POST` | `/fingerprint` | `file` (UploadFile, required), `peak_threshold` (float, default 0.5), `fan_value` (int, default 15) | `{filename, duration, peaks_count, fingerprints_count, fingerprints[{hash,time,delta}]}` |
| `POST` | `/match` | `query_file` (UploadFile, required), `reference_file` (UploadFile, required), `peak_threshold` (float, default 0.5), `fan_value` (int, default 15), `min_matches` (int, default 10) | `{query_file, reference_file, query_duration, reference_duration, matched, match_count, confidence, time_offset}` |
### Failure paths
- Missing required upload(s) -> HTTP 422 (FastAPI validation error envelope).
- Pipeline failure (unreadable audio, decode error) -> HTTP 500 with `detail` starting with `Failed to process audio file:` (for `/fingerprint`) or `Failed to match audio files:` (for `/match`).
## Implementation Notes
- A single module-level `AudioFingerprinter()` instance is reused across requests. It has no mutable per-request state; only its analysis parameters are shared, and those are configuration-stable.
- `tempfile.NamedTemporaryFile(delete=False)` is used so the descriptor can be closed while the file is still on disk for librosa to open by path. The unlink lives in a `finally` clause and uses `missing_ok=True` so the cleanup is idempotent even if librosa already moved or removed the file.
- The `match` endpoint persists both uploads inside a single nested `with` block; this guarantees both temp paths are bound before either pipeline call runs, simplifying the cleanup in the `finally` clause.
- `CORSMiddleware` is wired with `allow_credentials=True`, `allow_methods=["*"]`, `allow_headers=["*"]`. The TestClient suite exercises the preflight (`OPTIONS`) path for `http://localhost:3000` to ensure the configuration is live.
- The `version="0.1.0"` field on the `FastAPI(...)` constructor matches the `pyproject.toml` and `package.json` versions; bump all three in lockstep at graduation to `1.0.0`.

View File

@@ -0,0 +1,125 @@
"""FastAPI application for the Suaveplan audio-fingerprinting service."""
import tempfile
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from ..fingerprint import AudioFingerprinter
app = FastAPI(
title="Suaveplan Audio Fingerprinting API",
description="High-accuracy audio fingerprinting and matching service",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
fingerprinter = AudioFingerprinter()
@app.get("/")
async def root() -> dict[str, str]:
"""Health-check endpoint."""
return {"status": "ok", "service": "audio-fingerprinting"}
@app.post("/fingerprint")
async def create_fingerprint(
file: UploadFile = File(...),
peak_threshold: float = 0.5,
fan_value: int = 15,
) -> dict[str, Any]:
"""Generate a fingerprint from an uploaded audio file."""
suffix = Path(file.filename).suffix if file.filename else ""
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
try:
result = fingerprinter.fingerprint_file(
tmp_path, peak_threshold=peak_threshold, fan_value=fan_value
)
fingerprints_list = [
{"hash": fp[0], "time": fp[1], "delta": fp[2]}
for fp in result["fingerprints"]
]
return {
"filename": file.filename,
"duration": result["duration"],
"peaks_count": result["peaks_count"],
"fingerprints_count": result["fingerprints_count"],
"fingerprints": fingerprints_list,
}
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to process audio file: {exc}",
) from exc
finally:
Path(tmp_path).unlink(missing_ok=True)
@app.post("/match")
async def match_audio(
query_file: UploadFile = File(...),
reference_file: UploadFile = File(...),
peak_threshold: float = 0.5,
fan_value: int = 15,
min_matches: int = 10,
) -> dict[str, Any]:
"""Match two uploaded audio files."""
query_suffix = Path(query_file.filename).suffix if query_file.filename else ""
ref_suffix = (
Path(reference_file.filename).suffix if reference_file.filename else ""
)
with tempfile.NamedTemporaryFile(
delete=False, suffix=query_suffix
) as query_tmp, tempfile.NamedTemporaryFile(
delete=False, suffix=ref_suffix
) as ref_tmp:
query_tmp.write(await query_file.read())
ref_tmp.write(await reference_file.read())
query_path = query_tmp.name
ref_path = ref_tmp.name
try:
query_result = fingerprinter.fingerprint_file(
query_path, peak_threshold=peak_threshold, fan_value=fan_value
)
ref_result = fingerprinter.fingerprint_file(
ref_path, peak_threshold=peak_threshold, fan_value=fan_value
)
match_result = fingerprinter.match_fingerprints(
query_result["fingerprints"],
ref_result["fingerprints"],
min_matches=min_matches,
)
return {
"query_file": query_file.filename,
"reference_file": reference_file.filename,
"query_duration": query_result["duration"],
"reference_duration": ref_result["duration"],
"matched": match_result["matched"],
"match_count": match_result["match_count"],
"confidence": match_result["confidence"],
"time_offset": match_result["time_offset"],
}
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to match audio files: {exc}",
) from exc
finally:
Path(query_path).unlink(missing_ok=True)
Path(ref_path).unlink(missing_ok=True)

View File

@@ -0,0 +1 @@
export { PythonAudioService } from "./python-audio-service.js";

View File

@@ -0,0 +1,109 @@
# `client/python-audio-service.ts` — `PythonAudioService`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PythonAudioService` is the typed HTTP client TypeScript and browser callers use to talk to the Python-tier audio fingerprinting service. It wraps `fetch`, handles multipart `FormData` uploads, propagates `AbortController`-based timeouts, and translates the FastAPI error envelope (`{detail: string}`) into well-typed JavaScript `Error` instances with informative messages. The class is the only runtime export of the `@suaveplan/audio-fingerprint/client` subpath, and it is also re-exported from the package root.
The client deliberately depends on nothing beyond the platform `fetch`, `FormData`, and `AbortController` APIs. Browsers (Chrome, Firefox, Safari 14+, Edge) and modern server runtimes (Node 18+, Bun, Deno) all expose these, so the same code runs unchanged in every consumer tier. There is no axios, no isomorphic-fetch polyfill, and no transitive dependency closure to audit.
## Features
- Configurable `baseUrl` (default `http://localhost:8000`) and `timeout` in ms (default `30000`).
- `healthCheck()` — `GET /`, returns the typed `HealthCheckResponse`.
- `fingerprint(req)` — `POST /fingerprint` with `file` plus optional `peak_threshold` / `fan_value`.
- `match(req)` — `POST /match` with `query_file`, `reference_file`, plus optional `peak_threshold` / `fan_value` / `min_matches`.
- `AbortController`-based timeouts; on expiry the client rejects with `Error("Request timeout after Nms: <endpoint>")`.
- Error parsing: HTTP-non-OK responses are parsed as JSON; if the body is `{detail: "..."}`, that string becomes the `Error.message`. Otherwise the client falls back to `HTTP <status>: <statusText>`.
- Non-`Error` thrown values from `fetch` are normalised to `Error("Unknown error occurred: <string>")`.
## Usage: Basic
```ts
import { PythonAudioService } from "@suaveplan/audio-fingerprint/client";
const client = new PythonAudioService();
const health = await client.healthCheck();
// { status: "ok", service: "audio-fingerprinting" }
```
## Usage: Advanced
```ts
import { PythonAudioService } from "@suaveplan/audio-fingerprint";
import type {
FingerprintResponse,
MatchResponse,
} from "@suaveplan/audio-fingerprint/types";
const client = new PythonAudioService({
baseUrl: "https://audio-fp.internal.example.com",
timeout: 60_000,
});
async function identify(query: File, reference: File): Promise<MatchResponse> {
try {
const queryFingerprint: FingerprintResponse = await client.fingerprint({
file: query,
peak_threshold: 0.55,
fan_value: 18,
});
// ... persist queryFingerprint to corpus if needed
return await client.match({
query_file: query,
reference_file: reference,
peak_threshold: 0.55,
fan_value: 18,
min_matches: 25,
});
} catch (error) {
// The thrown message is either the FastAPI `detail`, a status-text
// fallback, or the timeout / unknown-error envelope.
console.error("identification failed:", (error as Error).message);
throw error;
}
}
```
## API Reference
### Constructor
```ts
constructor(config?: AudioFingerprintConfig)
```
| Field | Type | Default | Description |
|---|---|---|---|
| `config.baseUrl` | `string` | `"http://localhost:8000"` | Base URL of the FastAPI service. |
| `config.timeout` | `number` | `30000` | Per-request timeout in milliseconds. |
### Methods
| Method | Signature | Throws |
|---|---|---|
| `healthCheck` | `() => Promise<HealthCheckResponse>` | `Error` on HTTP error, timeout, or network failure. |
| `fingerprint` | `(req: FingerprintRequest) => Promise<FingerprintResponse>` | `Error` on HTTP error, timeout, or network failure. |
| `match` | `(req: MatchRequest) => Promise<MatchResponse>` | `Error` on HTTP error, timeout, or network failure. |
### Error envelope
| Cause | `Error.message` |
|---|---|
| Timeout | `Request timeout after <Nms>: <endpoint>` |
| HTTP non-OK with parseable `{detail: string}` body | `<detail>` |
| HTTP non-OK with non-JSON or `detail`-missing body | `HTTP <status>: <statusText>` |
| `fetch` throws a non-`Error` value | `Unknown error occurred: <stringified value>` |
| `fetch` throws a non-Abort `Error` | the original error is re-raised verbatim |
## Implementation Notes
- The timeout is implemented with a `setTimeout` that calls `controller.abort()`; the timer is cleared both on success and on failure (try/catch + redundant `clearTimeout` after the `await`). This avoids leaking timers in long-lived browsers tabs.
- `AbortError` is detected by `error.name === "AbortError"` rather than by `instanceof DOMException` to remain runtime-agnostic (DOMException is browser-only).
- Optional form fields are only appended when the caller supplies them; the FastAPI defaults remain authoritative.
- The error-body JSON parse is wrapped in a try/catch with no rethrow inside the catch — a parse failure should NOT mask the original HTTP error, only fall through to the status-text fallback.
- Type-only imports from `../types/audio-fingerprint.types.js` are used so the client carries zero runtime cost from the type definitions.
- The client has no peer dependency on any framework; consumers wrap it in their own React/Vue/Svelte hooks as appropriate. The sibling `@suaveplan/react-audio-fingerprint` package's `python-backend` subpath is the canonical React adapter.

View File

@@ -0,0 +1,128 @@
/**
* HTTP client for the Suaveplan audio-fingerprint Python service.
*
* Provides a typed interface for `GET /`, `POST /fingerprint`, and
* `POST /match` on the FastAPI surface defined in `src/api/app.py`.
*/
import type {
AudioFingerprintConfig,
ErrorResponse,
FingerprintRequest,
FingerprintResponse,
HealthCheckResponse,
MatchRequest,
MatchResponse,
} from "../types/audio-fingerprint.types.js";
const DEFAULT_BASE_URL = "http://localhost:8000";
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Typed client for the Python audio-fingerprinting FastAPI service.
*
* @example
* ```ts
* const client = new PythonAudioService({ baseUrl: "http://localhost:8000" });
* const health = await client.healthCheck();
* const result = await client.fingerprint({ file });
* const match = await client.match({ query_file, reference_file });
* ```
*/
export class PythonAudioService {
private readonly baseUrl: string;
private readonly timeout: number;
constructor(config: AudioFingerprintConfig = {}) {
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
}
/** Calls `GET /`. Returns `{ status: 'ok', service: 'audio-fingerprinting' }`. */
async healthCheck(): Promise<HealthCheckResponse> {
return this.request<HealthCheckResponse>("/");
}
/** Calls `POST /fingerprint` with the audio file and optional parameters. */
async fingerprint(req: FingerprintRequest): Promise<FingerprintResponse> {
const formData = new FormData();
formData.append("file", req.file);
if (req.peak_threshold !== undefined) {
formData.append("peak_threshold", String(req.peak_threshold));
}
if (req.fan_value !== undefined) {
formData.append("fan_value", String(req.fan_value));
}
return this.request<FingerprintResponse>("/fingerprint", {
method: "POST",
body: formData,
});
}
/** Calls `POST /match` with the query and reference files and options. */
async match(req: MatchRequest): Promise<MatchResponse> {
const formData = new FormData();
formData.append("query_file", req.query_file);
formData.append("reference_file", req.reference_file);
if (req.peak_threshold !== undefined) {
formData.append("peak_threshold", String(req.peak_threshold));
}
if (req.fan_value !== undefined) {
formData.append("fan_value", String(req.fan_value));
}
if (req.min_matches !== undefined) {
formData.append("min_matches", String(req.min_matches));
}
return this.request<MatchResponse>("/match", {
method: "POST",
body: formData,
});
}
/**
* Internal fetch wrapper. Adds `AbortController`-based timeouts and a
* uniform error surface: HTTP errors carry the FastAPI `detail` body if
* present, otherwise fall back to `HTTP <status>: <statusText>`.
*/
private async request<T>(endpoint: string, init?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.timeout);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = (await response.json()) as ErrorResponse;
if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parse failure -> fall through to HTTP status text.
}
throw new Error(errorMessage);
}
return (await response.json()) as T;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error(
`Request timeout after ${this.timeout}ms: ${endpoint}`
);
}
throw error;
}
throw new Error(`Unknown error occurred: ${String(error)}`);
}
}
}

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.

View File

@@ -0,0 +1,220 @@
"""Audio fingerprinting utilities using chromagram + constellation hashing."""
from pathlib import Path
from typing import Any
import librosa
import numpy as np
from numpy.typing import NDArray
from scipy.ndimage import maximum_filter
class AudioFingerprinter:
"""High-accuracy audio fingerprinting for track identification.
Uses chromagram-based fingerprinting with 2D peak detection and
constellation-map hashing for robust audio identification even with
noise, compression, or modifications. The matcher uses Jaccard
similarity over hashed peak pairs and reports median time offset.
"""
def __init__(
self,
sample_rate: int = 22050,
n_fft: int = 2048,
hop_length: int = 512,
n_chroma: int = 12,
) -> None:
"""Initialise fingerprinter with audio-analysis parameters.
Args:
sample_rate: Target sample rate for audio processing.
n_fft: FFT window size for frequency analysis.
hop_length: Number of samples between successive frames.
n_chroma: Number of chroma bins (12 for standard pitch classes).
"""
self.sample_rate = sample_rate
self.n_fft = n_fft
self.hop_length = hop_length
self.n_chroma = n_chroma
def load_audio(self, file_path: str | Path) -> NDArray[np.float32]:
"""Load and preprocess audio file to mono at the target sample rate.
Args:
file_path: Path to audio file (WAV, MP3, OGG, FLAC, etc.).
Returns:
Mono audio signal resampled to ``self.sample_rate``.
Raises:
FileNotFoundError: If the file does not exist.
RuntimeError: If librosa fails to decode the file.
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Audio file not found: {file_path}")
try:
audio, _ = librosa.load(str(path), sr=self.sample_rate, mono=True)
return audio
except Exception as exc:
raise RuntimeError(f"Failed to load audio file: {exc}") from exc
def extract_chromagram(
self, audio: NDArray[np.float32]
) -> NDArray[np.float32]:
"""Extract chromagram (pitch-class profile) from a mono signal.
Args:
audio: Mono audio signal.
Returns:
Chromagram matrix of shape ``(n_chroma, n_frames)``.
"""
stft = librosa.stft(
audio, n_fft=self.n_fft, hop_length=self.hop_length
)
chromagram = librosa.feature.chroma_stft(
S=np.abs(stft), sr=self.sample_rate, n_chroma=self.n_chroma
)
return chromagram
def find_peaks(
self,
chromagram: NDArray[np.float32],
threshold: float = 0.5,
) -> list[tuple[int, int, float]]:
"""Find 2D local maxima in the chromagram.
Uses ``scipy.ndimage.maximum_filter`` with a ``(3, 5)`` neighbourhood
and keeps only points whose normalised amplitude equals the local
maximum and exceeds ``threshold``.
Args:
chromagram: Chromagram from :meth:`extract_chromagram`.
threshold: Minimum peak amplitude after normalisation (0.0-1.0).
Returns:
List of ``(time_frame, chroma_bin, amplitude)`` tuples.
"""
max_val = float(np.max(chromagram))
if max_val == 0:
return []
normalized = chromagram / max_val
neighborhood_size = (3, 5)
local_max = maximum_filter(normalized, size=neighborhood_size)
peaks_mask = (normalized == local_max) & (normalized >= threshold)
peak_coords = np.argwhere(peaks_mask)
return [
(int(t), int(f), float(normalized[f, t])) for f, t in peak_coords
]
def generate_fingerprint(
self,
peaks: list[tuple[int, int, float]],
fan_value: int = 15,
) -> set[tuple[int, int, int]]:
"""Generate constellation-map fingerprint hashes from spectral peaks.
Pairs each peak with the next ``fan_value`` peaks in time-sorted
order and emits ``(hash(f1, f2, dt), t1, dt)`` tuples.
Args:
peaks: Peaks from :meth:`find_peaks`.
fan_value: Number of future peaks to pair with each peak.
Returns:
Set of ``(fingerprint_hash, t1, time_delta)`` tuples.
"""
sorted_peaks = sorted(peaks, key=lambda x: x[0])
fingerprints: set[tuple[int, int, int]] = set()
for i, (t1, f1, _) in enumerate(sorted_peaks):
for j in range(i + 1, min(i + fan_value + 1, len(sorted_peaks))):
t2, f2, _ = sorted_peaks[j]
time_delta = t2 - t1
fingerprint_hash = hash((f1, f2, time_delta))
fingerprints.add((fingerprint_hash, t1, time_delta))
return fingerprints
def fingerprint_file(
self,
file_path: str | Path,
peak_threshold: float = 0.5,
fan_value: int = 15,
) -> dict[str, Any]:
"""Run the full fingerprinting pipeline from a file path.
Args:
file_path: Path to audio file.
peak_threshold: Minimum peak amplitude (0.0-1.0).
fan_value: Number of peaks to pair for hashing.
Returns:
Dict with ``fingerprints`` (set), ``duration`` (s), ``peaks_count``
(int), ``fingerprints_count`` (int).
"""
audio = self.load_audio(file_path)
chromagram = self.extract_chromagram(audio)
peaks = self.find_peaks(chromagram, threshold=peak_threshold)
fingerprints = self.generate_fingerprint(peaks, fan_value=fan_value)
duration = len(audio) / self.sample_rate
return {
"fingerprints": fingerprints,
"duration": duration,
"peaks_count": len(peaks),
"fingerprints_count": len(fingerprints),
}
def match_fingerprints(
self,
query_fingerprints: set[tuple[int, int, int]],
reference_fingerprints: set[tuple[int, int, int]],
min_matches: int = 10,
) -> dict[str, Any]:
"""Match a query fingerprint set against a reference fingerprint set.
Args:
query_fingerprints: Fingerprints from the query audio.
reference_fingerprints: Fingerprints from the reference audio.
min_matches: Minimum overlap for a positive match.
Returns:
Dict with ``matched`` (bool), ``match_count`` (int),
``confidence`` (Jaccard similarity, 0.0-1.0), ``time_offset``
(median frame offset; 0 when no positive match).
"""
query_hashes = {fp[0] for fp in query_fingerprints}
reference_hashes = {fp[0] for fp in reference_fingerprints}
matching_hashes = query_hashes & reference_hashes
match_count = len(matching_hashes)
union_size = len(query_hashes | reference_hashes)
confidence = match_count / max(union_size, 1)
time_offset = 0
if match_count >= min_matches and matching_hashes:
# Build a hash -> reference-time lookup once, then walk the query
# set computing per-hash time deltas. The previous nested O(n*m)
# loop is replaced with O(n+m).
reference_time_by_hash: dict[int, int] = {}
for rfp in reference_fingerprints:
reference_time_by_hash.setdefault(rfp[0], rfp[1])
offsets: list[int] = [
reference_time_by_hash[qfp[0]] - qfp[1]
for qfp in query_fingerprints
if qfp[0] in matching_hashes
]
time_offset = int(np.median(offsets))
return {
"matched": match_count >= min_matches,
"match_count": match_count,
"confidence": confidence,
"time_offset": time_offset,
}

View File

@@ -0,0 +1,11 @@
export { PythonAudioService } from "./client/python-audio-service.js";
export type {
AudioFingerprint,
AudioFingerprintConfig,
ErrorResponse,
FingerprintRequest,
FingerprintResponse,
HealthCheckResponse,
MatchRequest,
MatchResponse,
} from "./types/audio-fingerprint.types.js";

View File

@@ -0,0 +1,71 @@
/**
* TypeScript type definitions for the Suaveplan audio-fingerprint Python
* service. These types correspond exactly to the wire shapes returned by
* the FastAPI endpoints defined in `src/api/app.py`.
*/
/** Health-check response from the `/` endpoint. */
export interface HealthCheckResponse {
status: "ok";
service: "audio-fingerprinting";
}
/** Individual fingerprint hash with timing information. */
export interface AudioFingerprint {
/** Hash of `(frequency1, frequency2, time_delta)`. */
hash: number;
/** Time position in the audio, in STFT frame indices. */
time: number;
/** Time delta between the paired peaks, in STFT frame indices. */
delta: number;
}
/** Response payload for the `/fingerprint` endpoint. */
export interface FingerprintResponse {
filename: string;
duration: number;
peaks_count: number;
fingerprints_count: number;
fingerprints: AudioFingerprint[];
}
/** Response payload for the `/match` endpoint. */
export interface MatchResponse {
query_file: string;
reference_file: string;
query_duration: number;
reference_duration: number;
matched: boolean;
match_count: number;
/** Jaccard similarity score in the range [0.0, 1.0]. */
confidence: number;
/** Median frame offset between matched sections. */
time_offset: number;
}
/** Request body for `/fingerprint`. The file is sent as multipart form data. */
export interface FingerprintRequest {
file: File | Blob;
peak_threshold?: number;
fan_value?: number;
}
/** Request body for `/match`. Files are sent as multipart form data. */
export interface MatchRequest {
query_file: File | Blob;
reference_file: File | Blob;
peak_threshold?: number;
fan_value?: number;
min_matches?: number;
}
/** Error response shape (matches FastAPI's `HTTPException` JSON). */
export interface ErrorResponse {
detail: string;
}
/** Configuration accepted by `PythonAudioService`. */
export interface AudioFingerprintConfig {
baseUrl?: string;
timeout?: number;
}

View File

@@ -0,0 +1,10 @@
export type {
AudioFingerprint,
AudioFingerprintConfig,
ErrorResponse,
FingerprintRequest,
FingerprintResponse,
HealthCheckResponse,
MatchRequest,
MatchResponse,
} from "./audio-fingerprint.types.js";