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,36 @@
"""Shared pytest fixtures for audio-fingerprint tests."""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
# Ensure the package source is importable as `src.*` when pytest is invoked
# from the package directory without an installed wheel.
_PKG_DIR = Path(__file__).resolve().parents[1]
if str(_PKG_DIR) not in sys.path:
sys.path.insert(0, str(_PKG_DIR))
@pytest.fixture
def sample_audio() -> tuple[np.ndarray, int]:
"""Generate a 2-second 440 Hz sine for tests."""
sample_rate = 22050
duration = 2.0
t = np.linspace(0, duration, int(sample_rate * duration))
audio = 0.5 * np.sin(2 * np.pi * 440.0 * t)
return audio.astype(np.float32), sample_rate
@pytest.fixture
def audio_file(sample_audio: tuple[np.ndarray, int]) -> Path:
"""Persist the sample audio to a temp WAV path."""
audio, sample_rate = sample_audio
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
sf.write(tmp.name, audio, sample_rate)
return Path(tmp.name)

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as clientBarrel from "../src/client/index.js";
import * as root from "../src/index.js";
import * as typesBarrel from "../src/types/index.js";
describe("audio-fingerprint exports", () => {
it("barrel re-exports the client and types", { timeout: 5000 }, () => {
expect(typeof root.PythonAudioService).toBe("function");
});
it("client subpath re-exports PythonAudioService", {
timeout: 5000,
}, () => {
expect(clientBarrel.PythonAudioService).toBe(root.PythonAudioService);
});
it("types subpath barrel is loadable (no runtime exports)", {
timeout: 5000,
}, () => {
// Types are erased at runtime; the barrel module exists but has
// no own enumerable runtime keys.
expect(Object.keys(typesBarrel)).toEqual([]);
});
});

View File

@@ -0,0 +1,306 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { PythonAudioService } from "../src/client/python-audio-service.js";
import type {
FingerprintResponse,
HealthCheckResponse,
MatchResponse,
} from "../src/types/audio-fingerprint.types.js";
type FetchHandler = (
url: string,
init?: RequestInit
) => Promise<Response> | Response;
const originalFetch = globalThis.fetch;
function installFetch(handler: FetchHandler): {
calls: Array<{ url: string; init?: RequestInit }>;
} {
const calls: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit
) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
calls.push({ url, init });
return handler(url, init);
}) as typeof fetch;
return { calls };
}
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
async function formDataEntries(
init: RequestInit | undefined
): Promise<Array<[string, FormDataEntryValue]>> {
if (!init || !(init.body instanceof FormData)) {
return [];
}
return Array.from(init.body.entries());
}
describe("PythonAudioService", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("constructor defaults", () => {
it("uses the documented baseUrl and timeout when no config is supplied", {
timeout: 5000,
}, async () => {
const { calls } = installFetch(() =>
jsonResponse({
status: "ok",
service: "audio-fingerprinting",
})
);
const client = new PythonAudioService();
await client.healthCheck();
expect(calls[0]?.url).toBe("http://localhost:8000/");
});
});
describe("healthCheck", () => {
it("healthCheck returns the parsed body", {
timeout: 5000,
}, async () => {
const payload: HealthCheckResponse = {
status: "ok",
service: "audio-fingerprinting",
};
installFetch(() => jsonResponse(payload));
const client = new PythonAudioService({
baseUrl: "http://example.test",
});
const result = await client.healthCheck();
expect(result).toEqual(payload);
});
});
describe("fingerprint", () => {
it("fingerprint posts the file and optional params via FormData", {
timeout: 5000,
}, async () => {
const payload: FingerprintResponse = {
filename: "test.wav",
duration: 1.0,
peaks_count: 1,
fingerprints_count: 1,
fingerprints: [{ hash: 1, time: 0, delta: 0 }],
};
const { calls } = installFetch(() => jsonResponse(payload));
const client = new PythonAudioService();
const file = new Blob(["RIFF...."], { type: "audio/wav" });
const result = await client.fingerprint({
file,
peak_threshold: 0.6,
fan_value: 20,
});
expect(result).toEqual(payload);
const entries = await formDataEntries(calls[0]?.init);
const keys = entries.map(([k]) => k);
expect(keys).toContain("file");
expect(entries).toContainEqual(["peak_threshold", "0.6"]);
expect(entries).toContainEqual(["fan_value", "20"]);
expect(calls[0]?.url).toBe("http://localhost:8000/fingerprint");
});
it("fingerprint omits optional params when undefined", {
timeout: 5000,
}, async () => {
const { calls } = installFetch(() =>
jsonResponse({
filename: "x",
duration: 0,
peaks_count: 0,
fingerprints_count: 0,
fingerprints: [],
})
);
const client = new PythonAudioService();
await client.fingerprint({ file: new Blob(["a"]) });
const entries = await formDataEntries(calls[0]?.init);
const keys = entries.map(([k]) => k);
expect(keys).toEqual(["file"]);
});
});
describe("match", () => {
it("match posts both files and all optional params", {
timeout: 5000,
}, async () => {
const payload: MatchResponse = {
query_file: "q.wav",
reference_file: "r.wav",
query_duration: 1.0,
reference_duration: 1.0,
matched: true,
match_count: 10,
confidence: 0.9,
time_offset: 0,
};
const { calls } = installFetch(() => jsonResponse(payload));
const client = new PythonAudioService();
const queryFile = new Blob(["q"], { type: "audio/wav" });
const referenceFile = new Blob(["r"], { type: "audio/wav" });
const result = await client.match({
query_file: queryFile,
reference_file: referenceFile,
peak_threshold: 0.6,
fan_value: 20,
min_matches: 5,
});
expect(result).toEqual(payload);
const entries = await formDataEntries(calls[0]?.init);
const keys = entries.map(([k]) => k);
expect(keys).toContain("query_file");
expect(keys).toContain("reference_file");
expect(entries).toContainEqual(["peak_threshold", "0.6"]);
expect(entries).toContainEqual(["fan_value", "20"]);
expect(entries).toContainEqual(["min_matches", "5"]);
});
it("match omits optional params when undefined", {
timeout: 5000,
}, async () => {
const { calls } = installFetch(() =>
jsonResponse({
query_file: "",
reference_file: "",
query_duration: 0,
reference_duration: 0,
matched: false,
match_count: 0,
confidence: 0,
time_offset: 0,
})
);
const client = new PythonAudioService();
await client.match({
query_file: new Blob(["a"]),
reference_file: new Blob(["b"]),
});
const entries = await formDataEntries(calls[0]?.init);
const keys = entries.map(([k]) => k);
expect(keys).toEqual(["query_file", "reference_file"]);
});
});
describe("error and timeout handling", () => {
let realSetTimeout: typeof setTimeout;
beforeEach(() => {
realSetTimeout = globalThis.setTimeout;
});
afterEach(() => {
globalThis.setTimeout = realSetTimeout;
});
it("timeout aborts the request", { timeout: 5000 }, async () => {
installFetch(
(_, init) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
throw new Error("missing AbortSignal");
}
signal.addEventListener("abort", () => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
});
})
);
const client = new PythonAudioService({ timeout: 5 });
await expect(client.healthCheck()).rejects.toThrow(
/Request timeout after 5ms: \//
);
});
it("non-json error body falls back to status text", {
timeout: 5000,
}, async () => {
installFetch(
() =>
new Response("not-json", {
status: 500,
statusText: "Internal Server Error",
headers: { "content-type": "text/plain" },
})
);
const client = new PythonAudioService();
await expect(client.healthCheck()).rejects.toThrow(
"HTTP 500: Internal Server Error"
);
});
it("json error body surfaces the detail field", {
timeout: 5000,
}, async () => {
installFetch(() =>
jsonResponse(
{ detail: "Failed to process audio file: bad" },
{ status: 500, statusText: "Internal Server Error" }
)
);
const client = new PythonAudioService();
await expect(client.healthCheck()).rejects.toThrow(
"Failed to process audio file: bad"
);
});
it("json error body without detail falls back to status text", {
timeout: 5000,
}, async () => {
installFetch(() =>
jsonResponse(
{ error: "missing-detail-field" },
{ status: 500, statusText: "Internal Server Error" }
)
);
const client = new PythonAudioService();
await expect(client.healthCheck()).rejects.toThrow(
"HTTP 500: Internal Server Error"
);
});
it("non-Error thrown by fetch surfaces as Unknown error", {
timeout: 5000,
}, async () => {
globalThis.fetch = (() => {
throw "raw-string-throw";
}) as typeof fetch;
const client = new PythonAudioService();
await expect(client.healthCheck()).rejects.toThrow(
"Unknown error occurred: raw-string-throw"
);
});
it("non-AbortError thrown by fetch is re-raised verbatim", {
timeout: 5000,
}, async () => {
globalThis.fetch = (() => {
throw new Error("network down");
}) as typeof fetch;
const client = new PythonAudioService();
await expect(client.healthCheck()).rejects.toThrow("network down");
});
});
});

View File

@@ -0,0 +1,224 @@
"""Tests for the FastAPI audio-fingerprinting service."""
from __future__ import annotations
import tempfile
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from src.api.app import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _make_bad_audio_file() -> Path:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False, mode="wb") as tmp:
tmp.write(b"not an audio file at all")
return Path(tmp.name)
def _make_different_audio() -> Path:
sample_rate = 22050
duration = 2.0
t = np.linspace(0, duration, int(sample_rate * duration))
audio = 0.5 * np.sin(2 * np.pi * 880.0 * t)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
sf.write(tmp.name, audio.astype(np.float32), sample_rate)
return Path(tmp.name)
def test_root_endpoint(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["service"] == "audio-fingerprinting"
def test_fingerprint_endpoint(client: TestClient, audio_file: Path) -> None:
with open(audio_file, "rb") as f:
response = client.post(
"/fingerprint", files={"file": ("test.wav", f, "audio/wav")}
)
assert response.status_code == 200
data = response.json()
assert data["filename"] == "test.wav"
assert data["duration"] > 0.0
assert data["peaks_count"] > 0
assert data["fingerprints_count"] > 0
assert isinstance(data["fingerprints"], list)
assert len(data["fingerprints"]) > 0
for fp in data["fingerprints"]:
assert "hash" in fp
assert "time" in fp
assert "delta" in fp
def test_fingerprint_endpoint_with_parameters(
client: TestClient, audio_file: Path
) -> None:
with open(audio_file, "rb") as f:
response = client.post(
"/fingerprint",
files={"file": ("test.wav", f, "audio/wav")},
data={"peak_threshold": 0.6, "fan_value": 20},
)
assert response.status_code == 200
assert response.json()["fingerprints_count"] > 0
def test_fingerprint_endpoint_no_file(client: TestClient) -> None:
response = client.post("/fingerprint")
assert response.status_code == 422
def test_fingerprint_endpoint_invalid_audio(client: TestClient) -> None:
bad = _make_bad_audio_file()
try:
with open(bad, "rb") as f:
response = client.post(
"/fingerprint",
files={"file": ("bad.wav", f, "audio/wav")},
)
assert response.status_code == 500
assert response.json()["detail"].startswith("Failed to process audio file:")
finally:
bad.unlink(missing_ok=True)
def test_match_endpoint(client: TestClient, audio_file: Path) -> None:
with open(audio_file, "rb") as q, open(audio_file, "rb") as r:
response = client.post(
"/match",
files={
"query_file": ("q.wav", q, "audio/wav"),
"reference_file": ("r.wav", r, "audio/wav"),
},
)
assert response.status_code == 200
data = response.json()
for key in (
"query_file",
"reference_file",
"query_duration",
"reference_duration",
"matched",
"match_count",
"confidence",
"time_offset",
):
assert key in data
assert data["matched"] is True
assert data["confidence"] > 0.9
assert abs(data["time_offset"]) <= 5
def test_match_endpoint_with_parameters(
client: TestClient, audio_file: Path
) -> None:
with open(audio_file, "rb") as q, open(audio_file, "rb") as r:
response = client.post(
"/match",
files={
"query_file": ("q.wav", q, "audio/wav"),
"reference_file": ("r.wav", r, "audio/wav"),
},
data={"peak_threshold": 0.6, "fan_value": 20, "min_matches": 5},
)
assert response.status_code == 200
assert response.json()["matched"] is True
def test_match_endpoint_different_files(
client: TestClient, audio_file: Path
) -> None:
different = _make_different_audio()
try:
with open(audio_file, "rb") as q, open(different, "rb") as r:
response = client.post(
"/match",
files={
"query_file": ("q.wav", q, "audio/wav"),
"reference_file": ("r.wav", r, "audio/wav"),
},
)
assert response.status_code == 200
data = response.json()
assert "confidence" in data
assert "matched" in data
finally:
different.unlink(missing_ok=True)
def test_match_endpoint_missing_files(client: TestClient) -> None:
response = client.post("/match")
assert response.status_code == 422
def test_match_endpoint_invalid_audio(
client: TestClient, audio_file: Path
) -> None:
bad = _make_bad_audio_file()
try:
with open(audio_file, "rb") as q, open(bad, "rb") as r:
response = client.post(
"/match",
files={
"query_file": ("q.wav", q, "audio/wav"),
"reference_file": ("bad.wav", r, "audio/wav"),
},
)
assert response.status_code == 500
assert response.json()["detail"].startswith("Failed to match audio files:")
finally:
bad.unlink(missing_ok=True)
def test_cors_headers(client: TestClient) -> None:
response = client.options(
"/fingerprint",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "POST",
},
)
assert response.status_code == 200
assert "access-control-allow-origin" in response.headers
@pytest.mark.asyncio
async def test_fingerprint_endpoint_async(audio_file: Path) -> None:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
with open(audio_file, "rb") as f:
response = await ac.post(
"/fingerprint", files={"file": ("test.wav", f, "audio/wav")}
)
assert response.status_code == 200
assert response.json()["fingerprints_count"] > 0
@pytest.mark.asyncio
async def test_match_endpoint_async(audio_file: Path) -> None:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
with open(audio_file, "rb") as q, open(audio_file, "rb") as r:
response = await ac.post(
"/match",
files={
"query_file": ("q.wav", q, "audio/wav"),
"reference_file": ("r.wav", r, "audio/wav"),
},
)
assert response.status_code == 200
assert response.json()["matched"] is True

View File

@@ -0,0 +1,229 @@
"""Tests for the AudioFingerprinter chromagram + constellation pipeline."""
from __future__ import annotations
import tempfile
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from src.fingerprint import AudioFingerprinter
@pytest.fixture
def fingerprinter() -> AudioFingerprinter:
return AudioFingerprinter()
def test_fingerprinter_initialization() -> None:
fp = AudioFingerprinter()
assert fp.sample_rate == 22050
assert fp.n_fft == 2048
assert fp.hop_length == 512
assert fp.n_chroma == 12
def test_fingerprinter_custom_parameters() -> None:
fp = AudioFingerprinter(
sample_rate=44100, n_fft=4096, hop_length=1024, n_chroma=24
)
assert fp.sample_rate == 44100
assert fp.n_fft == 4096
assert fp.hop_length == 1024
assert fp.n_chroma == 24
def test_load_audio(fingerprinter: AudioFingerprinter, audio_file: Path) -> None:
audio = fingerprinter.load_audio(audio_file)
assert isinstance(audio, np.ndarray)
assert audio.dtype == np.float32
assert len(audio) > 0
assert -1.0 <= audio.min() <= 1.0
assert -1.0 <= audio.max() <= 1.0
def test_load_audio_nonexistent_file(fingerprinter: AudioFingerprinter) -> None:
with pytest.raises(FileNotFoundError, match="Audio file not found"):
fingerprinter.load_audio("/nonexistent/file.wav")
def test_load_audio_invalid_data_raises_runtime_error(
fingerprinter: AudioFingerprinter,
) -> None:
"""A file that exists but isn't decodable must surface as RuntimeError."""
with tempfile.NamedTemporaryFile(
suffix=".wav", delete=False, mode="wb"
) as tmp:
tmp.write(b"this is not a wav file at all")
bad_path = Path(tmp.name)
try:
with pytest.raises(RuntimeError, match="Failed to load audio file"):
fingerprinter.load_audio(bad_path)
finally:
bad_path.unlink(missing_ok=True)
def test_extract_chromagram(
fingerprinter: AudioFingerprinter,
sample_audio: tuple[np.ndarray, int],
) -> None:
audio, _ = sample_audio
chromagram = fingerprinter.extract_chromagram(audio)
assert isinstance(chromagram, np.ndarray)
assert chromagram.shape[0] == fingerprinter.n_chroma
assert chromagram.shape[1] > 0
assert chromagram.min() >= 0.0
def test_find_peaks(
fingerprinter: AudioFingerprinter,
sample_audio: tuple[np.ndarray, int],
) -> None:
audio, _ = sample_audio
chromagram = fingerprinter.extract_chromagram(audio)
peaks = fingerprinter.find_peaks(chromagram, threshold=0.5)
assert isinstance(peaks, list)
assert len(peaks) > 0
for time, chroma, amplitude in peaks:
assert isinstance(time, int)
assert isinstance(chroma, int)
assert isinstance(amplitude, float)
assert 0 <= chroma < fingerprinter.n_chroma
assert 0.0 <= amplitude <= 1.0
def test_find_peaks_threshold(
fingerprinter: AudioFingerprinter,
sample_audio: tuple[np.ndarray, int],
) -> None:
audio, _ = sample_audio
chromagram = fingerprinter.extract_chromagram(audio)
peaks_low = fingerprinter.find_peaks(chromagram, threshold=0.3)
peaks_high = fingerprinter.find_peaks(chromagram, threshold=0.7)
assert len(peaks_low) >= len(peaks_high)
def test_find_peaks_zero_magnitude(fingerprinter: AudioFingerprinter) -> None:
"""A zero chromagram must return an empty peak list."""
zero_chroma = np.zeros((fingerprinter.n_chroma, 50), dtype=np.float32)
assert fingerprinter.find_peaks(zero_chroma) == []
def test_generate_fingerprint(
fingerprinter: AudioFingerprinter,
sample_audio: tuple[np.ndarray, int],
) -> None:
audio, _ = sample_audio
chromagram = fingerprinter.extract_chromagram(audio)
peaks = fingerprinter.find_peaks(chromagram)
fingerprints = fingerprinter.generate_fingerprint(peaks, fan_value=15)
assert isinstance(fingerprints, set)
assert len(fingerprints) > 0
for fp_hash, time, delta in fingerprints:
assert isinstance(fp_hash, int)
assert isinstance(time, int)
assert isinstance(delta, int)
assert time >= 0
assert delta >= 0
def test_generate_fingerprint_fan_value(
fingerprinter: AudioFingerprinter,
sample_audio: tuple[np.ndarray, int],
) -> None:
audio, _ = sample_audio
chromagram = fingerprinter.extract_chromagram(audio)
peaks = fingerprinter.find_peaks(chromagram)
fps_small = fingerprinter.generate_fingerprint(peaks, fan_value=5)
fps_large = fingerprinter.generate_fingerprint(peaks, fan_value=20)
assert len(fps_large) >= len(fps_small)
def test_fingerprint_file(
fingerprinter: AudioFingerprinter, audio_file: Path
) -> None:
result = fingerprinter.fingerprint_file(audio_file)
assert isinstance(result, dict)
for key in ("fingerprints", "duration", "peaks_count", "fingerprints_count"):
assert key in result
assert isinstance(result["fingerprints"], set)
assert len(result["fingerprints"]) > 0
assert result["duration"] > 0.0
assert result["peaks_count"] > 0
assert result["fingerprints_count"] == len(result["fingerprints"])
def test_match_fingerprints_identical(
fingerprinter: AudioFingerprinter, audio_file: Path
) -> None:
result1 = fingerprinter.fingerprint_file(audio_file)
result2 = fingerprinter.fingerprint_file(audio_file)
match = fingerprinter.match_fingerprints(
result1["fingerprints"], result2["fingerprints"], min_matches=10
)
assert match["matched"] is True
assert match["match_count"] > 0
assert match["confidence"] > 0.9
assert abs(match["time_offset"]) <= 5
def test_match_fingerprints_min_matches(
fingerprinter: AudioFingerprinter, audio_file: Path
) -> None:
result1 = fingerprinter.fingerprint_file(audio_file)
result2 = fingerprinter.fingerprint_file(audio_file)
match_reasonable = fingerprinter.match_fingerprints(
result1["fingerprints"], result2["fingerprints"], min_matches=10
)
match_impossible = fingerprinter.match_fingerprints(
result1["fingerprints"], result2["fingerprints"], min_matches=999999
)
assert match_reasonable["matched"] is True
assert match_impossible["matched"] is False
assert match_impossible["time_offset"] == 0
def test_match_fingerprints_empty(fingerprinter: AudioFingerprinter) -> None:
"""Empty inputs must yield zero match_count, zero confidence, zero offset."""
match = fingerprinter.match_fingerprints(set(), set(), min_matches=10)
assert match["match_count"] == 0
assert match["confidence"] == 0
assert match["time_offset"] == 0
assert match["matched"] is False
def test_match_fingerprints_with_offset(
fingerprinter: AudioFingerprinter,
) -> None:
"""When matches exist, the median time offset between hashes is reported."""
query = {(1001, 0, 3), (1002, 10, 4), (1003, 20, 5)}
reference = {(1001, 100, 3), (1002, 110, 4), (1003, 120, 5)}
match = fingerprinter.match_fingerprints(query, reference, min_matches=2)
assert match["matched"] is True
assert match["match_count"] == 3
assert match["time_offset"] == 100
def test_match_fingerprints_partial_overlap(
fingerprinter: AudioFingerprinter,
) -> None:
"""Query hashes not present in reference must be skipped without crashing."""
query = {(1001, 0, 3), (9999, 10, 4), (1003, 20, 5)}
reference = {(1001, 50, 3), (1003, 70, 5), (4242, 999, 9)}
match = fingerprinter.match_fingerprints(query, reference, min_matches=2)
assert match["match_count"] == 2
assert match["matched"] is True
# Offsets are 50 and 50 -> median 50.
assert match["time_offset"] == 50
def test_match_fingerprints_zero_threshold_empty_inputs(
fingerprinter: AudioFingerprinter,
) -> None:
"""min_matches=0 with empty inputs must not attempt median on empty list."""
match = fingerprinter.match_fingerprints(set(), set(), min_matches=0)
assert match["match_count"] == 0
assert match["matched"] is True # 0 >= 0
assert match["time_offset"] == 0