Initial Commit
This commit is contained in:
36
packages/media/audio/audio-fingerprint/tests/conftest.py
Normal file
36
packages/media/audio/audio-fingerprint/tests/conftest.py
Normal 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)
|
||||
24
packages/media/audio/audio-fingerprint/tests/exports.test.ts
Normal file
24
packages/media/audio/audio-fingerprint/tests/exports.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
224
packages/media/audio/audio-fingerprint/tests/test_api.py
Normal file
224
packages/media/audio/audio-fingerprint/tests/test_api.py
Normal 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
|
||||
229
packages/media/audio/audio-fingerprint/tests/test_fingerprint.py
Normal file
229
packages/media/audio/audio-fingerprint/tests/test_fingerprint.py
Normal 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
|
||||
Reference in New Issue
Block a user