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,91 @@
# @suaveplan/react-audio-fingerprint - React 19 surface for client-side audio fingerprinting (Shazam-style constellation algorithm) and identity matching. Consumes FFT/spectrogram primitives from @suaveplan/audio-analysis-spectrum so the audio family has a single FFT implementation. Universal fingerprint + matcher cores; React hook + accessible component on top.
React 19 surface for client-side audio fingerprinting and identity matching.
The package implements the classic Shazam-style constellation algorithm
(2D-peak detection + pair hashing + time-offset histogram matching) on top of
the FFT/spectrogram primitives from
[`@suaveplan/audio-analysis-spectrum`](../../../media/audio/audio-analysis-spectrum)
— there is exactly **one** FFT implementation across the audio family.
<!-- begin:auto-docs -->
## Documentation tree
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
- [test-setup](./src/test-setup.md) — This file is loaded by `vitest.config.ts` via `setupFiles` so every test in the package runs against a consistent jest-…
<!-- end:auto-docs -->
## Status
bootstrap landing — initial slice focused on:
- Six error codes via `defineCodes("REACT_AUDIO_FINGERPRINT", …)`.
- `peak-detector`, `constellation`, `audio-fingerprinter`.
- `fingerprint-matcher` (time-offset histogramming).
- `memory-corpus` (`Map`-backed, async-shaped, subscribable).
- `local-backend` (default `FingerprintBackend` running in-process).
- `useAudioFingerprint` hook (driver with abort + progress).
- `<FingerprintMatchDisplay>` component + stories.
Deferred to follow-up: `IndexedDbCorpus`, `<AudioFingerprintUploader>`,
`<FingerprintVisualizer>`, `python-backend`, `__benchmarks__`, integration +
purity + property + bundle-size tests.
## Quick Start
```tsx
import { decode } from "@suaveplan/audio-engine-browser/decode";
import {
LocalBackend,
MemoryCorpus,
FingerprintMatchDisplay,
useAudioFingerprint,
} from "@suaveplan/react-audio-fingerprint";
function MatchSurface({ file }: { file: File }) {
const [doc, setDoc] = useState<AudioDocument | undefined>(undefined);
useEffect(() => {
decode(file).then(setDoc);
}, [file]);
const { state, fingerprint } = useAudioFingerprint(doc);
return state === "done"
? <FingerprintMatchDisplay results={[]} />
: <progress />;
}
```
## Algorithm
`AudioFingerprinter` composes three deterministic stages:
1. **Spectrogram** (delegated to `@suaveplan/audio-analysis-spectrum`).
2. **2D peak detection** (`peak-detector`) — local-max sweep above the
`noiseFloor`, capped at `peaksPerFrame` per frame.
3. **Constellation pair hashing** (`constellation`) — 32-bit packed
`binA[12] | binB[12] | dt[8]` over each anchor's `fanout` next peaks
inside a `targetZoneSec` window.
Matching is identical to the original Wang (2003) paper: build a 10 ms-wide
histogram of `(candidateOffset − queryOffset)` per shared hash, return the
heaviest bin's count as the score and its midpoint as the alignment offset.
## License
MIT — see `LICENSE` at the repo root.

View File

@@ -0,0 +1,159 @@
{
"name": "@suaveplan/react-audio-fingerprint",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "React 19 surface for client-side audio fingerprinting (Shazam-style constellation algorithm) and identity matching. Consumes FFT/spectrogram primitives from @suaveplan/audio-analysis-spectrum so the audio family has a single FFT implementation. Universal fingerprint + matcher cores; React hook + accessible component on top.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./fingerprint": {
"import": {
"types": "./dist/fingerprint/index.d.ts",
"default": "./dist/fingerprint/index.js"
}
},
"./fingerprint/peak-detector": {
"import": {
"types": "./dist/fingerprint/peak-detector/index.d.ts",
"default": "./dist/fingerprint/peak-detector/index.js"
}
},
"./fingerprint/constellation": {
"import": {
"types": "./dist/fingerprint/constellation/index.d.ts",
"default": "./dist/fingerprint/constellation/index.js"
}
},
"./fingerprint/audio-fingerprinter": {
"import": {
"types": "./dist/fingerprint/audio-fingerprinter/index.d.ts",
"default": "./dist/fingerprint/audio-fingerprinter/index.js"
}
},
"./fingerprint/fingerprint-matcher": {
"import": {
"types": "./dist/fingerprint/fingerprint-matcher/index.d.ts",
"default": "./dist/fingerprint/fingerprint-matcher/index.js"
}
},
"./corpus": {
"import": {
"types": "./dist/corpus/index.d.ts",
"default": "./dist/corpus/index.js"
}
},
"./corpus/memory-corpus": {
"import": {
"types": "./dist/corpus/memory-corpus/index.d.ts",
"default": "./dist/corpus/memory-corpus/index.js"
}
},
"./backends": {
"import": {
"types": "./dist/backends/index.d.ts",
"default": "./dist/backends/index.js"
}
},
"./backends/local-backend": {
"import": {
"types": "./dist/backends/local-backend/index.d.ts",
"default": "./dist/backends/local-backend/index.js"
}
},
"./hooks": {
"import": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
}
},
"./hooks/use-audio-fingerprint": {
"import": {
"types": "./dist/hooks/use-audio-fingerprint/index.d.ts",
"default": "./dist/hooks/use-audio-fingerprint/index.js"
}
},
"./components": {
"import": {
"types": "./dist/components/index.d.ts",
"default": "./dist/components/index.js"
}
},
"./components/fingerprint-match-display": {
"import": {
"types": "./dist/components/fingerprint-match-display/index.d.ts",
"default": "./dist/components/fingerprint-match-display/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-analysis-spectrum": "workspace:^",
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/audio-engine-browser": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@testing-library/jest-dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@testing-library/user-event": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,2 @@
export { LocalBackend } from "./local-backend/local-backend.js";
export type { FingerprintBackend } from "./local-backend/local-backend.types.js";

View File

@@ -0,0 +1,2 @@
export { LocalBackend } from "./local-backend.js";
export type { FingerprintBackend } from "./local-backend.types.js";

View File

@@ -0,0 +1,66 @@
# `local-backend` — Default in-process `FingerprintBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LocalBackend` is the default implementation of the `FingerprintBackend`
contract that hooks and components depend on. It composes `AudioFingerprinter`
and `FingerprintMatcher` so callers get a single facade with the same shape
the optional `PythonBackend` exposes from its gated subpath. The class owns
no state apart from its two collaborator instances.
## Features
- Single facade for `fingerprint(doc, opts?)` and `match(query, corpus)`.
- `AbortSignal` and progress passthrough to the underlying fingerprinter.
- Pure in-process: no I/O, no fetch, no server dependency.
- Matches the `FingerprintBackend` interface byte-for-byte so it can be
swapped at runtime via a hook prop.
- Constructor takes no arguments — defaults rule.
## Usage: Basic Example
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const fp = await backend.fingerprint(doc);
```
## Usage: Advanced Example
Inject the backend into the hook explicitly to keep the default exporting tree
shaken when a custom backend is in use:
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const controller = new AbortController();
const fp = await backend.fingerprint(doc, {
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
const results = await backend.match(fp, corpus);
```
## API Reference
- `class LocalBackend implements FingerprintBackend`.
- `fingerprint(doc, opts?): Promise<Fingerprint>`.
- `match(query, corpus): Promise<MatchResult[]>`.
## Implementation Notes
The class deliberately avoids exposing the underlying `AudioFingerprinter` and
`FingerprintMatcher` instances so consumers cannot mutate them after
construction — this preserves determinism and keeps the public surface stable
across versions. There is no caching layer here; consumers wishing to cache
fingerprint results between renders should do so at the hook layer, where the
relationship to the React commit phase is explicit. Errors from the
underlying components propagate unchanged; the backend deliberately does NOT
re-wrap them so the original `ReactAudioFingerprintError` code surfaces to the
caller.

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeDoc, twoTone } from "../../../tests/fixtures.js";
import { MemoryCorpus } from "../../corpus/memory-corpus/memory-corpus.js";
import { LocalBackend } from "./local-backend.js";
describe("LocalBackend", () => {
it("fingerprints a document", { timeout: 30000 }, async () => {
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
const fp = await new LocalBackend().fingerprint(doc, {});
expect(fp.length).toBeGreaterThan(0);
});
it("matches against a populated corpus", { timeout: 30000 }, async () => {
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
const backend = new LocalBackend();
const fp = await backend.fingerprint(doc);
const corpus = new MemoryCorpus();
await corpus.add("ref", fp);
const ranked = await backend.match(fp, corpus);
expect(ranked[0]?.id).toBe("ref");
expect(ranked[0]?.score).toBeGreaterThan(0);
});
it("returns an empty match list against an empty corpus", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
const backend = new LocalBackend();
const fp = await backend.fingerprint(doc);
const corpus = new MemoryCorpus();
const ranked = await backend.match(fp, corpus);
expect(ranked).toEqual([]);
});
});

View File

@@ -0,0 +1,41 @@
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
import { AudioFingerprinter } from "../../fingerprint/audio-fingerprinter/audio-fingerprinter.js";
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
import { FingerprintMatcher } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.js";
import type {
CorpusLike,
MatchResult,
} from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
import type { FingerprintBackend } from "./local-backend.types.js";
/**
* Default `FingerprintBackend` — runs the constellation fingerprinter and
* histogram matcher in the current process. This is the only backend
* shipped with the package root; the optional `PythonBackend` lives in
* `./backends/python-backend` and is excluded from the root tree-shake.
*/
export class LocalBackend implements FingerprintBackend {
private readonly fingerprinter = new AudioFingerprinter();
private readonly matcher = new FingerprintMatcher();
public async fingerprint(
doc: AudioDocument,
opts?: {
signal?: AbortSignal;
onProgress?: (p: { fraction: number }) => void;
}
): Promise<Fingerprint> {
const result = await this.fingerprinter.fingerprint(doc, {
...(opts?.signal ? { signal: opts.signal } : {}),
...(opts?.onProgress ? { onProgress: opts.onProgress } : {}),
});
return result.fingerprint;
}
public async match(
query: Fingerprint,
corpus: CorpusLike
): Promise<MatchResult[]> {
return this.matcher.match(query, corpus);
}
}

View File

@@ -0,0 +1,22 @@
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
import type {
CorpusLike,
MatchResult,
} from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
/**
* Backend abstraction: hooks accept an injected backend so consumers
* can swap the local algorithm for a server-side implementation without
* touching the React surface.
*/
export interface FingerprintBackend {
fingerprint(
doc: AudioDocument,
opts?: {
signal?: AbortSignal;
onProgress?: (p: { fraction: number }) => void;
}
): Promise<Fingerprint>;
match(query: Fingerprint, corpus: CorpusLike): Promise<MatchResult[]>;
}

View File

@@ -0,0 +1,67 @@
# `FingerprintMatchDisplay` — Ranked results list
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match`
as an accessible ranked list. Each row shows the result identifier, a
confidence bar whose width is proportional to the top score, and the recovered
time offset in seconds. The component is intentionally lightweight — it has
no fetching logic, no internal state, and no animation — so consumers can
compose it inside any layout without conflicts.
## Features
- Empty-state message via `<p role="status">`.
- Confidence bar with proper `role="progressbar"` and `aria-valuemin/max/now`.
- Optional `onSelect` callback renders the row as a `<button>` for keyboard
activation.
- Optional `renderTitle` to override the default `id` label.
- Accessible `aria-label` on the wrapping list (`"Match results"` default).
- Zero side-effects; pure rendering function.
## Usage: Basic Example
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay results={ranked} />;
```
## Usage: Advanced Example
Map IDs to friendlier titles and listen for selection:
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay
results={ranked}
label="Top matches in your library"
onSelect={(r) => navigateToTrack(r.id)}
renderTitle={(r) => library.titles[r.id] ?? r.id}
/>;
```
## API Reference
- `FingerprintMatchDisplayProps = { results, label?, onSelect?, renderTitle? }`.
- `results: ReadonlyArray<MatchResult>`.
- `label?: string` (default `"Match results"`).
- `onSelect?: (result: MatchResult) => void`.
- `renderTitle?: (result: MatchResult) => string`.
## Implementation Notes
The component never mutates `props.results`. The confidence bar's width
percentage is computed against the top score (`results[0].score`) so the best
match always reads 100% and the rest scale proportionally — this provides an
intuitive visual ranking regardless of the absolute hash counts. When
`onSelect` is supplied each row renders as a `<button type="button">` so the
default keyboard behaviour (Enter / Space) just works; without `onSelect` the
rows are plain `<li>` to avoid spurious focus stops. The empty state uses
`role="status"` (rather than rendering nothing) so assistive tech announces
"no matches" rather than silently leaving the user in the dark.

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { MatchResult } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
import { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
const SAMPLE: ReadonlyArray<MatchResult> = [
{ id: "song-1", score: 240, offsetSec: 1.23, matchedHashCount: 240 },
{ id: "song-2", score: 110, offsetSec: 3.5, matchedHashCount: 110 },
{ id: "song-3", score: 40, offsetSec: 0.5, matchedHashCount: 40 },
];
const meta: Meta<typeof FingerprintMatchDisplay> = {
title: "Fingerprint/FingerprintMatchDisplay",
component: FingerprintMatchDisplay,
};
export default meta;
type Story = StoryObj<typeof FingerprintMatchDisplay>;
export const Default: Story = {
args: { results: SAMPLE },
};
export const Empty: Story = {
args: { results: [] },
};
export const Selectable: Story = {
args: {
results: SAMPLE,
onSelect: (r) => console.log("selected", r),
},
};
export const CustomLabel: Story = {
args: {
results: SAMPLE,
label: "Top library matches",
renderTitle: (r) => `track [${r.id}]`,
},
};

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MatchResult } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
import { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
const sample: ReadonlyArray<MatchResult> = [
{ id: "alpha", score: 100, offsetSec: 1.23, matchedHashCount: 100 },
{ id: "beta", score: 60, offsetSec: 2.5, matchedHashCount: 60 },
{ id: "gamma", score: 20, offsetSec: 0.01, matchedHashCount: 20 },
];
describe("FingerprintMatchDisplay", () => {
it("renders the empty-state message", { timeout: 5000 }, () => {
render(<FingerprintMatchDisplay results={[]} />);
expect(screen.getByRole("status").textContent).toContain("No matches");
});
it("renders one row per result with progressbar semantics", {
timeout: 5000,
}, () => {
render(<FingerprintMatchDisplay results={sample} />);
expect(screen.getByRole("list").children.length).toBe(3);
const bars = screen.getAllByRole("progressbar");
expect(bars.length).toBe(3);
expect(bars[0]!.getAttribute("aria-valuenow")).toBe("100");
});
it("uses renderTitle when provided", { timeout: 5000 }, () => {
render(
<FingerprintMatchDisplay
results={sample}
renderTitle={(r) => `track-${r.id}`}
/>
);
expect(screen.getByText("track-alpha")).toBeTruthy();
});
it("fires onSelect on click", { timeout: 5000 }, async () => {
let selected: MatchResult | undefined;
render(
<FingerprintMatchDisplay
results={sample}
onSelect={(r) => {
selected = r;
}}
/>
);
await userEvent.click(screen.getAllByRole("button")[0]!);
expect(selected?.id).toBe("alpha");
});
it("fires onSelect via keyboard (Enter)", { timeout: 5000 }, async () => {
let count = 0;
render(
<FingerprintMatchDisplay
results={sample}
onSelect={() => {
count++;
}}
/>
);
const buttons = screen.getAllByRole("button");
buttons[1]!.focus();
await userEvent.keyboard("{Enter}");
expect(count).toBe(1);
});
it("applies a custom label", { timeout: 5000 }, () => {
render(<FingerprintMatchDisplay results={sample} label="Top hits" />);
expect(screen.getByLabelText("Top hits")).toBeTruthy();
});
it("labels the empty state with the supplied label", {
timeout: 5000,
}, () => {
render(<FingerprintMatchDisplay results={[]} label="Top hits" />);
expect(screen.getByLabelText("Top hits")).toBeTruthy();
});
it("uses a width of 100% for a top score of 0", { timeout: 5000 }, () => {
const zero: MatchResult[] = [
{ id: "z", score: 0, offsetSec: 0, matchedHashCount: 0 },
];
render(<FingerprintMatchDisplay results={zero} />);
const bar = screen.getByRole("progressbar");
expect(bar.getAttribute("style")).toContain("width: 0%");
});
});

View File

@@ -0,0 +1,77 @@
import type { ReactElement } from "react";
import type { FingerprintMatchDisplayProps } from "./fingerprint-match-display.types.js";
/**
* Ranked match results with accessible confidence bars.
*
* - Renders a `<ul role="list">` of results, sorted as provided.
* - Each row shows the title, confidence bar (width ∝ score), and the
* absolute offset in seconds.
* - Empty input renders a `<p role="status">` message instead of a list
* so screen-reader users learn the search produced nothing.
* - Optional `onSelect` lets parents react to row activation; rows are
* keyboard-accessible when `onSelect` is provided (button-shaped).
*/
export function FingerprintMatchDisplay(
props: FingerprintMatchDisplayProps
): ReactElement {
const label = props.label ?? "Match results";
if (props.results.length === 0) {
return (
<p role="status" aria-label={label}>
No matches found.
</p>
);
}
const top = props.results[0]!.score || 1;
return (
<ul aria-label={label}>
{props.results.map((result) => {
const title = props.renderTitle
? props.renderTitle(result)
: result.id;
const widthPct = Math.min(
100,
Math.round((result.score / top) * 100)
);
if (props.onSelect) {
return (
<li key={result.id}>
<button
type="button"
onClick={() => props.onSelect?.(result)}
aria-label={`${title} score ${result.score} offset ${result.offsetSec.toFixed(2)} seconds`}
>
<span>{title}</span>
<span
role="progressbar"
aria-valuemin={0}
aria-valuemax={top}
aria-valuenow={result.score}
style={{ width: `${widthPct}%` }}
/>
<span>{result.offsetSec.toFixed(2)}s</span>
</button>
</li>
);
}
return (
<li key={result.id}>
<span>{title}</span>
<span
role="progressbar"
aria-valuemin={0}
aria-valuemax={top}
aria-valuenow={result.score}
style={{ width: `${widthPct}%` }}
/>
<span>{result.offsetSec.toFixed(2)}s</span>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,12 @@
import type { MatchResult } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
export interface FingerprintMatchDisplayProps {
/** Ranked results from `FingerprintMatcher.match`. */
readonly results: ReadonlyArray<MatchResult>;
/** Optional accessible label. Defaults to `"Match results"`. */
readonly label?: string;
/** Called when the user activates a row. */
readonly onSelect?: (result: MatchResult) => void;
/** Optional render override for each row's title. */
readonly renderTitle?: (result: MatchResult) => string;
}

View File

@@ -0,0 +1,2 @@
export { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
export type { FingerprintMatchDisplayProps } from "./fingerprint-match-display.types.js";

View File

@@ -0,0 +1,2 @@
export { FingerprintMatchDisplay } from "./fingerprint-match-display/fingerprint-match-display.js";
export type { FingerprintMatchDisplayProps } from "./fingerprint-match-display/fingerprint-match-display.types.js";

View File

@@ -0,0 +1,2 @@
export { MemoryCorpus } from "./memory-corpus/memory-corpus.js";
export type { Corpus } from "./memory-corpus/memory-corpus.types.js";

View File

@@ -0,0 +1,2 @@
export { MemoryCorpus } from "./memory-corpus.js";
export type { Corpus } from "./memory-corpus.types.js";

View File

@@ -0,0 +1,72 @@
# `memory-corpus` — In-memory `Map`-backed fingerprint corpus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryCorpus` is the default `Corpus` implementation: a `Map<string,
Fingerprint>` wrapped with a `Promise`-returning surface and a subscription
hook. It satisfies the matcher's `CorpusLike` contract and the React hook's
`subscribe` requirement, making it the simplest store you can plug into the
fingerprint pipeline. The asynchronous API matches the `IndexedDbCorpus`
follow-up so caller code does not need to branch on backing store.
## Features
- `add`, `remove`, `get`, `size`, `values`, `subscribe`.
- Sync work behind an async surface — no I/O cost in tests.
- Notifies subscribers on every successful mutation.
- Throws `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED` on invalid IDs.
- `subscribe(listener)` returns an unsubscribe function (React-friendly).
- Iteration order matches insertion order.
## Usage: Basic Example
```ts
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
await corpus.add("song-1", fingerprint);
console.log(corpus.size()); // 1
```
## Usage: Advanced Example
Subscribe to mutations from a `useSyncExternalStore` consumer and clean up on
unmount:
```ts
import { useSyncExternalStore } from "react";
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
function useCorpusSize(): number {
return useSyncExternalStore(
(cb) => corpus.subscribe(cb),
() => corpus.size(),
() => 0
);
}
```
## API Reference
- `class MemoryCorpus implements Corpus`.
- `add(id, fingerprint): Promise<void>`.
- `remove(id): Promise<void>`.
- `get(id): Promise<FingerprintCorpusEntry | undefined>`.
- `size(): number`.
- `values(): IterableIterator<FingerprintCorpusEntry>`.
- `subscribe(listener): () => void`.
## Implementation Notes
The listener set is a `Set` so subscribe/unsubscribe is O(1) and duplicate
registrations are de-duped. `remove` only notifies when the key actually
existed; this matches the React convention that no-op mutations should not
trigger a render. Empty-string IDs are rejected so consumers cannot create a
ghost entry that `get("")` would later return — the matcher relies on `id`
being a stable label. Iteration uses a generator so callers can `break` early
without paying for a full copy of the store.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import { MemoryCorpus } from "./memory-corpus.js";
describe("MemoryCorpus", () => {
it("stores and retrieves a fingerprint", { timeout: 5000 }, async () => {
const corpus = new MemoryCorpus();
await corpus.add("a", [{ hash: 1, timeOffsetSec: 0 }]);
const entry = await corpus.get("a");
expect(entry?.id).toBe("a");
expect(entry?.fingerprint.length).toBe(1);
});
it("returns undefined for an unknown id", { timeout: 5000 }, async () => {
const corpus = new MemoryCorpus();
expect(await corpus.get("missing")).toBeUndefined();
});
it("tracks size across add/remove", { timeout: 5000 }, async () => {
const corpus = new MemoryCorpus();
expect(corpus.size()).toBe(0);
await corpus.add("a", []);
await corpus.add("b", []);
expect(corpus.size()).toBe(2);
await corpus.remove("a");
expect(corpus.size()).toBe(1);
});
it("iterates in insertion order", { timeout: 5000 }, async () => {
const corpus = new MemoryCorpus();
await corpus.add("a", [{ hash: 1, timeOffsetSec: 0 }]);
await corpus.add("b", [{ hash: 2, timeOffsetSec: 0 }]);
await corpus.add("c", [{ hash: 3, timeOffsetSec: 0 }]);
const ids = [...corpus.values()].map((e) => e.id);
expect(ids).toEqual(["a", "b", "c"]);
});
it("fires subscribers on add and remove", { timeout: 5000 }, async () => {
const corpus = new MemoryCorpus();
let count = 0;
const unsub = corpus.subscribe(() => {
count++;
});
await corpus.add("a", []);
await corpus.remove("a");
expect(count).toBe(2);
unsub();
await corpus.add("b", []);
expect(count).toBe(2);
});
it("does not fire subscribers on a no-op remove", {
timeout: 5000,
}, async () => {
const corpus = new MemoryCorpus();
let count = 0;
corpus.subscribe(() => {
count++;
});
await corpus.remove("missing");
expect(count).toBe(0);
});
it("throws CORPUS_FAILED on an empty id", { timeout: 5000 }, async () => {
const corpus = new MemoryCorpus();
try {
await corpus.add("", []);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED
);
}
});
it("throws CORPUS_FAILED on a non-string id", {
timeout: 5000,
}, async () => {
const corpus = new MemoryCorpus();
try {
await corpus.add(undefined as unknown as string, []);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED
);
}
});
});

View File

@@ -0,0 +1,61 @@
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
import type { FingerprintCorpusEntry } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
import type { Corpus } from "./memory-corpus.types.js";
/**
* `Map`-backed corpus implementation. Mutations are synchronous; the
* `Promise`-returning surface exists so the same API works under
* `IndexedDbCorpus` (follow-up) without branching at call sites.
*
* Every mutation notifies subscribers — used by `useFingerprintMatcher`
* to re-run matches when the corpus changes.
*/
export class MemoryCorpus implements Corpus {
private readonly store = new Map<string, Fingerprint>();
private readonly listeners = new Set<() => void>();
public async add(id: string, fingerprint: Fingerprint): Promise<void> {
if (typeof id !== "string" || id.length === 0) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED,
"react-audio-fingerprint.corpus.invalid-id",
{ context: { id } }
);
}
this.store.set(id, fingerprint);
this.notify();
}
public async remove(id: string): Promise<void> {
if (this.store.delete(id)) this.notify();
}
public async get(id: string): Promise<FingerprintCorpusEntry | undefined> {
const fingerprint = this.store.get(id);
if (!fingerprint) return undefined;
return { id, fingerprint };
}
public size(): number {
return this.store.size;
}
public *values(): IterableIterator<FingerprintCorpusEntry> {
for (const [id, fingerprint] of this.store) yield { id, fingerprint };
}
public subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notify(): void {
for (const fn of this.listeners) fn();
}
}

View File

@@ -0,0 +1,21 @@
import type {
CorpusLike,
FingerprintCorpusEntry,
} from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
/**
* Public corpus contract. `MemoryCorpus` is one concrete implementation;
* `IndexedDbCorpus` (follow-up) is another. The `'changed'` event fires
* after every mutation so React hooks can re-subscribe via
* `useSyncExternalStore`.
*/
export interface Corpus extends CorpusLike {
add(
id: string,
fingerprint: FingerprintCorpusEntry["fingerprint"]
): Promise<void>;
remove(id: string): Promise<void>;
get(id: string): Promise<FingerprintCorpusEntry | undefined>;
size(): number;
subscribe(listener: () => void): () => void;
}

View File

@@ -0,0 +1,89 @@
# `error-codes` — Stable error registry for `@suaveplan/react-audio-fingerprint`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares every error code that `@suaveplan/react-audio-fingerprint`
can throw. Each code is registered through `defineCodes` from
`@suaveplan/error`, producing namespaced strings (e.g.
`REACT_AUDIO_FINGERPRINT.DECODE_FAILED`) that downstream code, telemetry, and
i18n catalogues can branch on without depending on free-form messages. The
companion `ReactAudioFingerprintError` class extends `SuaveplanError`, carries
the code as a structural field, and round-trips through the error wire format
defined by the error package.
## Features
- Six explicit codes covering the full failure surface: decode, fingerprint,
match, corpus, abort, and option validation.
- Frozen metadata table keyed by code with `description` and `severity`.
- `ReactAudioFingerprintError` class with stable `category = "react-audio-fingerprint"`.
- Idempotent `registerReactAudioFingerprintErrorCodes()` hook for parity with
sibling packages.
- Strict TypeScript typing — codes are inferred from the literal tuple.
## Usage: Basic Example
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "@suaveplan/react-audio-fingerprint/error-codes";
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED,
"react-audio-fingerprint.decode.failed",
{ context: { mime: "audio/mp3" } }
);
```
## Usage: Advanced Example
Discriminate caught errors by code and surface a structured toast:
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
} from "@suaveplan/react-audio-fingerprint/error-codes";
try {
await fingerprint(source);
} catch (e) {
if (e instanceof ReactAudioFingerprintError) {
const meta = REACT_AUDIO_FINGERPRINT_ERROR_METADATA[e.code];
if (e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED) {
return; // user-driven cancel, no toast
}
toast.error(`${meta.severity}: ${meta.description}`);
}
throw e;
}
```
## API Reference
- `REACT_AUDIO_FINGERPRINT_CODES` — frozen object whose keys/values are the
six dotted code strings.
- `ReactAudioFingerprintErrorCode` — union of valid code strings.
- `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` — frozen lookup keyed by code.
- `ReactAudioFingerprintError extends SuaveplanError` — `code`, `messageKey`,
optional `cause` / `context`, `category: "react-audio-fingerprint"`.
- `registerReactAudioFingerprintErrorCodes()` — no-op registration hook for
symmetry with packages that defer metadata registration to runtime.
## Implementation Notes
The metadata table is `Object.freeze`d so consumers cannot mutate it. Every
code in `REACT_AUDIO_FINGERPRINT_CODES` MUST have a metadata entry; the test
suite asserts the two sets line up via `Object.keys`. The class sets
`this.name` to include the severity for friendlier debugger output without
mutating the underlying code, leaving structural equality intact. Registration
runs at module load via the side-effectless hook so importing the barrel is
sufficient to make codes discoverable from the central registry. No biome
suppression comments are present; the file passes biome's strict ruleset
unaided.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_FINGERPRINT_CODES,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
ReactAudioFingerprintError,
registerReactAudioFingerprintErrorCodes,
} from "./error-codes.js";
describe("REACT_AUDIO_FINGERPRINT error codes", () => {
it("every registered code has metadata", { timeout: 5000 }, () => {
for (const code of Object.values(REACT_AUDIO_FINGERPRINT_CODES)) {
expect(REACT_AUDIO_FINGERPRINT_ERROR_METADATA[code]).toBeDefined();
}
});
it("every metadata key matches a registered code", {
timeout: 5000,
}, () => {
const codes = new Set<string>(
Object.values(REACT_AUDIO_FINGERPRINT_CODES)
);
for (const key of Object.keys(REACT_AUDIO_FINGERPRINT_ERROR_METADATA)) {
expect(codes.has(key)).toBe(true);
}
});
it("every code is reachable from a real call site", {
timeout: 5000,
}, () => {
// The package's own tests instantiate every code via the
// dedicated throw paths; this test enforces the catalogue
// invariant by constructing each one explicitly.
for (const code of Object.values(REACT_AUDIO_FINGERPRINT_CODES)) {
const err = new ReactAudioFingerprintError(
code,
`react-audio-fingerprint.test.${code}`
);
expect(err.code).toBe(code);
expect(err).toBeInstanceOf(ReactAudioFingerprintError);
}
});
it("carries the category tag", { timeout: 5000 }, () => {
const err = new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
"x"
);
expect(err.category).toBe("react-audio-fingerprint");
});
it("registerReactAudioFingerprintErrorCodes is idempotent", {
timeout: 5000,
}, () => {
expect(() => {
registerReactAudioFingerprintErrorCodes();
registerReactAudioFingerprintErrorCodes();
}).not.toThrow();
});
});

View File

@@ -0,0 +1,106 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error codes published by `@suaveplan/react-audio-fingerprint`.
*
* Every code is namespaced under `REACT_AUDIO_FINGERPRINT.` by
* {@link defineCodes}, producing dotted strings such as
* `REACT_AUDIO_FINGERPRINT.DECODE_FAILED`. The six codes cover the full
* failure surface of the fingerprint package: source decoding (via
* `audio-engine-browser`), the constellation algorithm itself, corpus
* lookup/match, in-flight cancellation, and Zod option validation.
*/
export const REACT_AUDIO_FINGERPRINT_CODES = defineCodes(
"REACT_AUDIO_FINGERPRINT",
[
"DECODE_FAILED",
"FINGERPRINT_FAILED",
"MATCH_FAILED",
"CORPUS_FAILED",
"ABORTED",
"INVALID_OPTIONS",
] as const
);
export type ReactAudioFingerprintErrorCode =
(typeof REACT_AUDIO_FINGERPRINT_CODES)[keyof typeof REACT_AUDIO_FINGERPRINT_CODES];
export interface ReactAudioFingerprintErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
/**
* Frozen registry of metadata keyed by code. Every code in
* {@link REACT_AUDIO_FINGERPRINT_CODES} has exactly one entry; the test
* suite cross-checks the two sets.
*/
export const REACT_AUDIO_FINGERPRINT_ERROR_METADATA: Readonly<
Record<ReactAudioFingerprintErrorCode, ReactAudioFingerprintErrorMetadata>
> = Object.freeze({
[REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED]: {
description:
"the supplied audio source could not be decoded to an AudioDocument via @suaveplan/audio-engine-browser.",
severity: "error",
},
[REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED]: {
description:
"the constellation fingerprinter raised while computing the spectrogram or detecting peaks.",
severity: "error",
},
[REACT_AUDIO_FINGERPRINT_CODES.MATCH_FAILED]: {
description:
"the matcher could not score the query against the supplied corpus (e.g. corpus iteration threw).",
severity: "error",
},
[REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED]: {
description:
"a corpus mutation (add/remove/get) failed; the underlying store is in an inconsistent state.",
severity: "error",
},
[REACT_AUDIO_FINGERPRINT_CODES.ABORTED]: {
description:
"the caller's AbortSignal fired before the fingerprint or match completed.",
severity: "warning",
},
[REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS]: {
description:
"fingerprint/match options failed Zod boundary validation.",
severity: "error",
},
});
/**
* Base error class for every error thrown by
* `@suaveplan/react-audio-fingerprint`. Every instance carries a code from
* {@link REACT_AUDIO_FINGERPRINT_CODES}, a `messageKey` suitable for i18n
* resolution, and optional `context` / `cause`.
*
* Extends {@link SuaveplanError} so consumers can discriminate via
* `instanceof` or the stable `error.code` property.
*/
export class ReactAudioFingerprintError extends SuaveplanError<ReactAudioFingerprintErrorCode> {
public readonly category = "react-audio-fingerprint" as const;
public constructor(
code: ReactAudioFingerprintErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = `ReactAudioFingerprintError(${REACT_AUDIO_FINGERPRINT_ERROR_METADATA[code].severity})`;
}
}
/**
* Idempotent registration hook. The metadata is frozen at module load
* and re-exported directly; this function exists for parity with the
* `registerXxxErrorCodes()` convention adopted by sibling Suaveplan
* packages and is safe to call any number of times.
*/
export function registerReactAudioFingerprintErrorCodes(): void {
/* metadata is module-static; no-op. */
}
registerReactAudioFingerprintErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
ReactAudioFingerprintErrorCode,
ReactAudioFingerprintErrorMetadata,
} from "./error-codes.js";
export {
REACT_AUDIO_FINGERPRINT_CODES,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
ReactAudioFingerprintError,
registerReactAudioFingerprintErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,73 @@
# `audio-fingerprinter` — Spectrogram → peaks → constellation orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioFingerprinter` is the entry point that turns an `AudioDocument` into a
deterministic constellation fingerprint. It composes three pieces: the
spectrogram from `@suaveplan/audio-analysis-spectrum` (the ONLY FFT used by
this package), the local-peak detector defined alongside this file, and the
constellation pair-hasher. The class owns no DSP — every numerical step is
delegated, which keeps the fingerprint algorithm in one auditable file.
## Features
- Zod-validated options (`frameSize`, `hopSize`, `window`, `noiseFloor`,
`peaksPerFrame`, `neighbourhood`, `fanout`, `targetZoneSec`).
- Cooperative cancellation via `AbortSignal` checked at every pipeline stage.
- Monotonic progress callback (`{ fraction }`) reported at 0, 0.5, 0.8, 1.
- Wraps spectrogram failures in `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`
while preserving the underlying `cause`.
- Stable output: identical input bytes produce a byte-equal fingerprint.
- Returns the sample rate, hop, and frame size alongside the fingerprint so
the matcher / visualizer can reconstruct frame ↔ time arithmetic.
## Usage: Basic Example
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const fp = await new AudioFingerprinter().fingerprint(doc);
console.log(fp.fingerprint.length, "pairs");
```
## Usage: Advanced Example
Pass an `AbortSignal` and a progress callback while running a non-default
window:
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const controller = new AbortController();
const fp = await new AudioFingerprinter().fingerprint(doc, {
window: "blackman",
frameSize: 2048,
hopSize: 512,
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
```
## API Reference
- `class AudioFingerprinter`.
- `fingerprint(doc, opts?): Promise<FingerprintResult>`.
- `FingerprintResult = { fingerprint, sampleRate, hopSize, frameSize }`.
- `FingerprintOptionsSchema` — Zod schema; defaults `frameSize=4096`,
`hopSize=1024`, `window="hann"`, `targetZoneSec=5`.
- `signal?: AbortSignal`, `onProgress?: ({ fraction }) => void`.
## Implementation Notes
`targetZoneFrames` is derived from `targetZoneSec` using the document's actual
`sampleRate` and the configured `hopSize`. This keeps the target zone musically
consistent across rates (e.g. 16 kHz speech vs 48 kHz music). The class only
re-throws a `ReactAudioFingerprintError` when it can identify the original
failure as an abort; everything else becomes `FINGERPRINT_FAILED` with the
original error as `cause`. There is no caching — every call re-runs the
spectrogram. Callers wanting to fingerprint the same document twice should
memoise the result themselves.

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeDoc, sineWave, twoTone } from "../../../tests/fixtures.js";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import { AudioFingerprinter } from "./audio-fingerprinter.js";
describe("AudioFingerprinter.fingerprint", () => {
it("produces a non-empty fingerprint for a two-tone clip", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.5, 440, 1320, 48_000), 48_000);
const result = await new AudioFingerprinter().fingerprint(doc, {
frameSize: 2048,
hopSize: 512,
});
expect(result.fingerprint.length).toBeGreaterThan(0);
expect(result.sampleRate).toBe(48_000);
expect(result.hopSize).toBe(512);
expect(result.frameSize).toBe(2048);
});
it("applies defaults when no options are supplied", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
const result = await new AudioFingerprinter().fingerprint(doc);
expect(result.frameSize).toBe(4096);
expect(result.hopSize).toBe(1024);
expect(result.sampleRate).toBe(48_000);
});
it("is deterministic on identical input", { timeout: 30000 }, async () => {
const samples = sineWave(0.3, 440, 48_000);
const docA = makeDoc(samples, 48_000);
const docB = makeDoc(samples, 48_000);
const fp = new AudioFingerprinter();
const a = await fp.fingerprint(docA, { frameSize: 2048, hopSize: 512 });
const b = await fp.fingerprint(docB, { frameSize: 2048, hopSize: 512 });
expect(a.fingerprint).toEqual(b.fingerprint);
});
it("reports monotonically increasing progress ending at 1", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
const seen: number[] = [];
await new AudioFingerprinter().fingerprint(doc, {
frameSize: 2048,
hopSize: 512,
onProgress: ({ fraction }) => seen.push(fraction),
});
expect(seen[0]).toBe(0);
expect(seen[seen.length - 1]).toBe(1);
for (let i = 1; i < seen.length; i++) {
expect(seen[i]!).toBeGreaterThanOrEqual(seen[i - 1]!);
}
});
it("throws ABORTED when the signal is already aborted", {
timeout: 5000,
}, async () => {
const doc = makeDoc(sineWave(0.1, 440, 48_000), 48_000);
const ctrl = new AbortController();
ctrl.abort();
try {
await new AudioFingerprinter().fingerprint(doc, {
frameSize: 2048,
hopSize: 512,
signal: ctrl.signal,
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
);
}
});
it("throws INVALID_OPTIONS on a negative hop size", {
timeout: 5000,
}, async () => {
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
try {
await new AudioFingerprinter().fingerprint(doc, {
frameSize: 4096,
hopSize: -1,
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
);
}
});
it("wraps spectrogram-stage failures in FINGERPRINT_FAILED", {
timeout: 5000,
}, async () => {
// A frame size that survives the local schema (positive int)
// but is rejected by the upstream analyzer (non-power-of-two)
// produces an opaque AudioAnalysisSpectrumError. The fingerprinter
// wraps it in FINGERPRINT_FAILED.
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
try {
await new AudioFingerprinter().fingerprint(doc, {
frameSize: 7,
hopSize: 1,
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
);
}
});
it("wraps unexpected analyzer throws via FINGERPRINT_FAILED", {
timeout: 5000,
}, async () => {
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
const fp = new AudioFingerprinter();
// Force the internal analyzer to throw an opaque (non-fingerprint)
// error by patching its analyze() method; this exercises the
// wrap path that turns external errors into FINGERPRINT_FAILED.
(
fp as unknown as { spectrogram: { analyze: () => Promise<never> } }
).spectrogram.analyze = async () => {
throw new Error("synthetic analyzer failure");
};
try {
await fp.fingerprint(doc, { frameSize: 2048, hopSize: 512 });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
);
}
});
it("re-throws ABORTED when the signal fires mid-analysis", {
timeout: 5000,
}, async () => {
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
const ctrl = new AbortController();
const fp = new AudioFingerprinter();
(
fp as unknown as { spectrogram: { analyze: () => Promise<never> } }
).spectrogram.analyze = async () => {
ctrl.abort();
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
"synthetic"
);
};
try {
await fp.fingerprint(doc, {
frameSize: 2048,
hopSize: 512,
signal: ctrl.signal,
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
);
}
});
it("checks abort after the peak-detector stage", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
const ctrl = new AbortController();
const fp = new AudioFingerprinter();
try {
await fp.fingerprint(doc, {
frameSize: 2048,
hopSize: 512,
signal: ctrl.signal,
onProgress: ({ fraction }) => {
if (fraction >= 0.5 && !ctrl.signal.aborted) ctrl.abort();
},
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
);
}
});
it("checks abort just before constellation pair construction", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
const ctrl = new AbortController();
const fp = new AudioFingerprinter();
try {
await fp.fingerprint(doc, {
frameSize: 2048,
hopSize: 512,
signal: ctrl.signal,
onProgress: ({ fraction }) => {
if (fraction >= 0.8 && !ctrl.signal.aborted) ctrl.abort();
},
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
);
}
});
});

View File

@@ -0,0 +1,134 @@
import { Spectrogram } from "@suaveplan/audio-analysis-spectrum";
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
import z from "zod";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import { buildConstellation } from "../constellation/constellation.js";
import { detectPeaks } from "../peak-detector/peak-detector.js";
import type {
FingerprintOptions,
FingerprintResult,
} from "./audio-fingerprinter.types.js";
/**
* Pre-parse option shape — every field optional; the schema applies the
* documented defaults.
*/
export const FingerprintOptionsSchema = z.object({
frameSize: z.number().int().positive().default(4096),
hopSize: z.number().int().positive().default(1024),
window: z.enum(["hann", "hamming", "blackman"]).default("hann"),
noiseFloor: z.number().nonnegative().default(1e-3),
peaksPerFrame: z.number().int().positive().default(5),
neighbourhood: z.number().int().positive().default(3),
fanout: z.number().int().positive().default(5),
targetZoneSec: z.number().positive().default(5),
});
export type FingerprintOptionsInput = z.input<typeof FingerprintOptionsSchema>;
function checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
"react-audio-fingerprint.fingerprint.aborted"
);
}
}
/**
* `AudioFingerprinter` composes the FFT/spectrogram primitives from
* `@suaveplan/audio-analysis-spectrum` with this package's peak detector
* and constellation pair-hasher. The class deliberately owns NO DSP —
* it is a thin co-ordinator that produces a deterministic
* {@link FingerprintResult}.
*/
export class AudioFingerprinter {
private readonly spectrogram = new Spectrogram();
public async fingerprint(
doc: AudioDocument,
raw?: FingerprintOptionsInput & {
signal?: AbortSignal;
onProgress?: (p: { fraction: number }) => void;
}
): Promise<FingerprintResult> {
const parsed = FingerprintOptionsSchema.safeParse(raw ?? {});
if (!parsed.success) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
"react-audio-fingerprint.fingerprint.invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data as Omit<
FingerprintOptions,
"signal" | "onProgress"
>;
const signal = raw?.signal;
const onProgress = raw?.onProgress;
checkAborted(signal);
raw?.onProgress?.({ fraction: 0 });
let specResult: Awaited<ReturnType<Spectrogram["analyze"]>>;
try {
specResult = await this.spectrogram.analyze(doc, {
frameSize: opts.frameSize,
hopSize: opts.hopSize,
window: opts.window,
...(signal ? { signal } : {}),
});
} catch (err) {
if (
err instanceof ReactAudioFingerprintError &&
err.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED
) {
throw err;
}
checkAborted(signal);
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED,
"react-audio-fingerprint.fingerprint.spectrogram-failed",
{ cause: err }
);
}
onProgress?.({ fraction: 0.5 });
checkAborted(signal);
const peaks = detectPeaks(specResult.spectrogram, {
neighbourhood: opts.neighbourhood,
noiseFloor: opts.noiseFloor,
peaksPerFrame: opts.peaksPerFrame,
});
onProgress?.({ fraction: 0.8 });
checkAborted(signal);
const targetZoneFrames = Math.max(
1,
Math.round((opts.targetZoneSec * doc.sampleRate) / opts.hopSize)
);
const fingerprint = buildConstellation(
peaks,
opts.hopSize,
doc.sampleRate,
{
fanout: opts.fanout,
targetZoneFrames,
minDeltaFrames: 1,
}
);
onProgress?.({ fraction: 1 });
return {
fingerprint,
sampleRate: doc.sampleRate,
hopSize: opts.hopSize,
frameSize: opts.frameSize,
};
}
}

View File

@@ -0,0 +1,38 @@
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
import type { Fingerprint } from "../constellation/constellation.types.js";
export interface FingerprintProgress {
/** 0..1 monotonic progress fraction. */
readonly fraction: number;
}
export type FingerprintProgressListener = (
progress: FingerprintProgress
) => void;
export interface FingerprintOptions {
readonly frameSize: number;
readonly hopSize: number;
readonly window: "hann" | "hamming" | "blackman";
readonly noiseFloor: number;
readonly peaksPerFrame: number;
readonly neighbourhood: number;
readonly fanout: number;
readonly targetZoneSec: number;
readonly signal: AbortSignal | undefined;
readonly onProgress: FingerprintProgressListener | undefined;
}
export interface FingerprintResult {
readonly fingerprint: Fingerprint;
readonly sampleRate: number;
readonly hopSize: number;
readonly frameSize: number;
}
export interface AudioFingerprinterLike {
fingerprint(
doc: AudioDocument,
opts?: Partial<FingerprintOptions>
): Promise<FingerprintResult>;
}

View File

@@ -0,0 +1,12 @@
export {
AudioFingerprinter,
type FingerprintOptionsInput,
FingerprintOptionsSchema,
} from "./audio-fingerprinter.js";
export type {
AudioFingerprinterLike,
FingerprintOptions,
FingerprintProgress,
FingerprintProgressListener,
FingerprintResult,
} from "./audio-fingerprinter.types.js";

View File

@@ -0,0 +1,69 @@
# `constellation` — Pair-hash construction over detected peaks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The constellation module converts a list of spectrogram peaks into a stable,
hashable fingerprint by pairing each anchor peak with several subsequent peaks
inside a fixed target zone. Each pair `(binA, binB, Δframes)` is packed into a
32-bit integer; the resulting `(hash, timeOffsetSec)` records form the
fingerprint that the matcher consumes. This is the classic Shazam construction
described by Wang (2003) without any modifications.
## Features
- 32-bit packed hash with explicit `binA[12] | binB[12] | dt[8]` layout.
- `packHash` / `unpackHash` round-trip helpers for diagnostics and tests.
- Configurable `fanout`, `targetZoneFrames`, `minDeltaFrames` via Zod schema.
- Deterministic output sorted by anchor `timeOffsetSec`.
- Empty-input safe (no peaks → empty fingerprint, no throw).
- Validates `hopSamples` / `sampleRate` and rejects non-positive grids with
`ReactAudioFingerprintError(INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 512, 48_000);
console.log(fp.length, fp[0]?.hash);
```
## Usage: Advanced Example
Tighten the target zone for short percussive clips:
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 256, 48_000, {
fanout: 8,
targetZoneFrames: 80,
minDeltaFrames: 2,
});
```
## API Reference
- `buildConstellation(peaks, hopSamples, sampleRate, opts?): Fingerprint`.
- `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`.
- `packHash(binA, binB, dt): number` — 32-bit pack.
- `unpackHash(h): { binA; binB; dt }` — inverse.
- `ConstellationOptionsSchema` — Zod schema with defaults `fanout=5`,
`targetZoneFrames=200`, `minDeltaFrames=1`.
## Implementation Notes
The Δt field is clamped to 8 bits, which limits the effective target zone to
255 hop-frames regardless of the schema value. At the default 512-sample hop
and 48 kHz sample rate this is ~2.72 seconds — well inside the classic
constellation target-zone width. Hashes are unsigned 32-bit integers; the
`>>> 0` at the end of `packHash` reinterprets the bitwise result, which is
otherwise a signed 32-bit integer in JavaScript's number type. Output sorting
makes equal-fingerprint comparison trivial (`JSON.stringify` round-trip is
enough for byte equality). Pair construction is O(`peaks × fanout`) because
each anchor stops emitting once the fanout cap is hit or the target zone is
exhausted.

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Peak } from "../peak-detector/peak-detector.types.js";
import { buildConstellation, packHash, unpackHash } from "./constellation.js";
const HOP = 512;
const SR = 48_000;
describe("packHash / unpackHash", () => {
it("round-trips arbitrary fields", { timeout: 5000 }, () => {
const h = packHash(123, 456, 78);
expect(unpackHash(h)).toEqual({ binA: 123, binB: 456, dt: 78 });
});
it("masks fields outside their bit-width", { timeout: 5000 }, () => {
const h = packHash(0xffff, 0xffff, 0xffff);
expect(unpackHash(h)).toEqual({ binA: 0x0fff, binB: 0x0fff, dt: 0xff });
});
it("returns an unsigned 32-bit integer", { timeout: 5000 }, () => {
const h = packHash(0x0fff, 0x0fff, 0xff);
expect(h >= 0).toBe(true);
expect(h <= 0xffffffff).toBe(true);
});
});
describe("buildConstellation", () => {
it("returns empty on empty peaks", { timeout: 5000 }, () => {
expect(buildConstellation([], HOP, SR)).toEqual([]);
});
it("throws INVALID_OPTIONS on bad grid", { timeout: 5000 }, () => {
const peaks: Peak[] = [{ frame: 0, bin: 1, magnitude: 1 }];
try {
buildConstellation(peaks, 0, SR);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
);
}
});
it("rejects negative sample rate via INVALID_OPTIONS", {
timeout: 5000,
}, () => {
const peaks: Peak[] = [{ frame: 0, bin: 1, magnitude: 1 }];
try {
buildConstellation(peaks, HOP, -1);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
}
});
it("rejects invalid fanout via Zod schema", { timeout: 5000 }, () => {
const peaks: Peak[] = [{ frame: 0, bin: 1, magnitude: 1 }];
try {
buildConstellation(peaks, HOP, SR, { fanout: -1 });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
);
}
});
it("emits pairs sorted by anchor time", { timeout: 5000 }, () => {
const peaks: Peak[] = [
{ frame: 10, bin: 1, magnitude: 1 },
{ frame: 12, bin: 5, magnitude: 1 },
{ frame: 20, bin: 9, magnitude: 1 },
];
const fp = buildConstellation(peaks, HOP, SR, {
fanout: 5,
targetZoneFrames: 50,
minDeltaFrames: 1,
});
expect(fp.length).toBeGreaterThan(0);
for (let i = 1; i < fp.length; i++) {
expect(fp[i]!.timeOffsetSec).toBeGreaterThanOrEqual(
fp[i - 1]!.timeOffsetSec
);
}
});
it("drops pairs outside the target zone", { timeout: 5000 }, () => {
const peaks: Peak[] = [
{ frame: 0, bin: 1, magnitude: 1 },
{ frame: 500, bin: 2, magnitude: 1 },
];
const fp = buildConstellation(peaks, HOP, SR, {
fanout: 5,
targetZoneFrames: 100,
minDeltaFrames: 1,
});
expect(fp).toEqual([]);
});
it("honours fanout cap", { timeout: 5000 }, () => {
const peaks: Peak[] = [
{ frame: 0, bin: 1, magnitude: 1 },
{ frame: 1, bin: 2, magnitude: 1 },
{ frame: 2, bin: 3, magnitude: 1 },
{ frame: 3, bin: 4, magnitude: 1 },
{ frame: 4, bin: 5, magnitude: 1 },
];
const fp = buildConstellation(peaks, HOP, SR, {
fanout: 2,
targetZoneFrames: 50,
minDeltaFrames: 1,
});
// Anchor 0 contributes 2, anchor 1 contributes 2, anchor 2
// contributes 2, anchor 3 contributes 1, anchor 4 contributes 0.
expect(fp.length).toBe(7);
});
it("honours minDeltaFrames", { timeout: 5000 }, () => {
const peaks: Peak[] = [
{ frame: 0, bin: 1, magnitude: 1 },
{ frame: 0, bin: 2, magnitude: 1 },
{ frame: 5, bin: 3, magnitude: 1 },
];
const fp = buildConstellation(peaks, HOP, SR, {
fanout: 5,
targetZoneFrames: 50,
minDeltaFrames: 1,
});
// The same-frame pair is dropped; only anchor[0]->peak[2] and
// anchor[1]->peak[2] remain.
expect(fp.length).toBe(2);
});
it("produces deterministic output", { timeout: 5000 }, () => {
const peaks: Peak[] = [
{ frame: 0, bin: 1, magnitude: 1 },
{ frame: 3, bin: 9, magnitude: 1 },
{ frame: 7, bin: 4, magnitude: 1 },
];
const a = buildConstellation(peaks, HOP, SR);
const b = buildConstellation(peaks, HOP, SR);
expect(a).toEqual(b);
});
});

View File

@@ -0,0 +1,116 @@
import z from "zod";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Peak } from "../peak-detector/peak-detector.types.js";
import type {
ConstellationOptions,
Fingerprint,
FingerprintPair,
} from "./constellation.types.js";
/**
* Defaults: fanout=5 (each anchor pairs with up to 5 future peaks),
* targetZoneFrames=200 (~ a few seconds at 512 hop / 48 kHz), and a
* minimum 1-frame separation to avoid self-pairs at the same time slice.
*/
export const ConstellationOptionsSchema = z.object({
fanout: z.number().int().positive().default(5),
targetZoneFrames: z.number().int().positive().default(200),
minDeltaFrames: z.number().int().nonnegative().default(1),
});
export type ConstellationOptionsInput = z.input<
typeof ConstellationOptionsSchema
>;
function parseOptions(raw?: ConstellationOptionsInput): ConstellationOptions {
const result = ConstellationOptionsSchema.safeParse(raw ?? {});
if (!result.success) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
"react-audio-fingerprint.constellation.invalid-options",
{ context: { issues: result.error.issues } }
);
}
return result.data;
}
/**
* Pack a pair `(binA, binB, dt)` into a 32-bit hash.
* Layout: `binA[12 bits] | binB[12 bits] | dt[8 bits]`. Bins are masked
* to 12 bits (0..4095, covers a 4096-point FFT's 2049 bins). Δt is
* clamped to 0..255 frames — pairs outside that range are rejected by
* the caller and never reach this function.
*/
export function packHash(binA: number, binB: number, dt: number): number {
const a = binA & 0x0fff;
const b = binB & 0x0fff;
const d = dt & 0xff;
// Result is a 32-bit unsigned integer; explicit `>>> 0` to keep TS
// happy about the sign bit when binA dominates the top nibble.
return ((a << 20) | (b << 8) | d) >>> 0;
}
/**
* Decode a hash back into its three fields. Inverse of {@link packHash}.
*/
export function unpackHash(hash: number): {
binA: number;
binB: number;
dt: number;
} {
return {
binA: (hash >>> 20) & 0x0fff,
binB: (hash >>> 8) & 0x0fff,
dt: hash & 0xff,
};
}
/**
* Build a constellation fingerprint from detected peaks. For each anchor
* peak the function emits up to `fanout` pair-hashes against subsequent
* peaks whose frame difference falls within `[minDeltaFrames,
* targetZoneFrames]` AND ≤ 255 (the wire limit imposed by the 8-bit Δt
* field in {@link packHash}). Output is sorted by ascending
* `timeOffsetSec`. The conversion from frame index to seconds uses
* `hopSamples / sampleRate`.
*/
export function buildConstellation(
peaks: ReadonlyArray<Peak>,
hopSamples: number,
sampleRate: number,
raw?: ConstellationOptionsInput
): Fingerprint {
const opts = parseOptions(raw);
if (peaks.length === 0) return [];
if (hopSamples <= 0 || sampleRate <= 0) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
"react-audio-fingerprint.constellation.bad-grid",
{ context: { hopSamples, sampleRate } }
);
}
const secPerFrame = hopSamples / sampleRate;
const out: FingerprintPair[] = [];
const maxDt = Math.min(opts.targetZoneFrames, 255);
for (let i = 0; i < peaks.length; i++) {
const anchor = peaks[i]!;
let emitted = 0;
for (let j = i + 1; j < peaks.length && emitted < opts.fanout; j++) {
const target = peaks[j]!;
const dt = target.frame - anchor.frame;
if (dt < opts.minDeltaFrames) continue;
if (dt > maxDt) break;
out.push({
hash: packHash(anchor.bin, target.bin, dt),
timeOffsetSec: anchor.frame * secPerFrame,
});
emitted++;
}
}
out.sort((a, b) => a.timeOffsetSec - b.timeOffsetSec);
return out;
}

View File

@@ -0,0 +1,21 @@
/**
* A constellation pair hash plus its anchor time offset.
*
* - `hash` — 32-bit unsigned integer packing `{ binA, binB, dt }`.
* - `timeOffsetSec` — anchor-peak time in seconds.
*/
export interface FingerprintPair {
readonly hash: number;
readonly timeOffsetSec: number;
}
/**
* A complete fingerprint — a time-sorted list of constellation pairs.
*/
export type Fingerprint = ReadonlyArray<FingerprintPair>;
export interface ConstellationOptions {
readonly fanout: number;
readonly targetZoneFrames: number;
readonly minDeltaFrames: number;
}

View File

@@ -0,0 +1,12 @@
export {
buildConstellation,
type ConstellationOptionsInput,
ConstellationOptionsSchema,
packHash,
unpackHash,
} from "./constellation.js";
export type {
ConstellationOptions,
Fingerprint,
FingerprintPair,
} from "./constellation.types.js";

View File

@@ -0,0 +1,67 @@
# `fingerprint-matcher` — Time-offset histogram match scoring
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FingerprintMatcher` scores a query fingerprint against a corpus of candidate
fingerprints using the classic Shazam time-offset histogramming technique. For
every hash that appears in both query and candidate, the matcher accumulates
`(candidateOffset − queryOffset)` into a 10 ms-wide histogram bucket; the
heaviest bucket count is the match score and its midpoint is the alignment
offset. Results sort by descending score, ready for ranked display.
## Features
- `match(query, corpus)` returns ranked `MatchResult[]`.
- `matchOne(query, candidate)` returns the best bin only — useful for unit
tests and direct pairwise comparison.
- Configurable `binWidthSec` (default 0.01 s).
- Hash-index pre-step keeps the cost at `O(|query| + |candidate|)` per pair.
- Wraps iteration-time corpus failures in
`REACT_AUDIO_FINGERPRINT.MATCH_FAILED`.
- Empty inputs return zero-scored results without throwing.
## Usage: Basic Example
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const ranked = new FingerprintMatcher().match(queryFp, corpus);
console.log(ranked[0]?.id, ranked[0]?.score);
```
## Usage: Advanced Example
Drive a tighter alignment window for short clips:
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const m = new FingerprintMatcher();
const one = m.matchOne(queryFp, candidateFp, { binWidthSec: 0.005 });
console.log(one.score, one.offsetSec, one.matchedHashCount);
```
## API Reference
- `class FingerprintMatcher`.
- `match(query, corpus, opts?): MatchResult[]`.
- `matchOne(query, candidate, opts?): MatchOneResult`.
- `MatchResult = { id, score, offsetSec, matchedHashCount }`.
- `CorpusLike = { values(): Iterable<{ id; fingerprint }> }`.
- `MatcherOptions = { binWidthSec?: number }` (default `0.01`).
## Implementation Notes
A candidate's hash table is built lazily on each call — caching is left to
callers because corpora are typically streamed. Hash indices use `Map<number,
number[]>` since duplicate hashes inside one fingerprint are common (the same
constellation pair can recur at different times). The histogram is also a
`Map<number, number>` because the bucket range is unbounded in principle.
Bucket midpoints (rather than left edges) keep the reported offset symmetric
around the true alignment. The matcher is pure — given the same inputs it
produces the same outputs — so callers can safely memoise results keyed by
`(queryFp, corpusVersion)`.

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Fingerprint } from "../constellation/constellation.types.js";
import { FingerprintMatcher } from "./fingerprint-matcher.js";
function fp(pairs: ReadonlyArray<[number, number]>): Fingerprint {
return pairs.map(([hash, timeOffsetSec]) => ({ hash, timeOffsetSec }));
}
describe("FingerprintMatcher.matchOne", () => {
it("returns zero on empty query", { timeout: 5000 }, () => {
const r = new FingerprintMatcher().matchOne(fp([]), fp([[1, 0]]));
expect(r).toEqual({ score: 0, offsetSec: 0, matchedHashCount: 0 });
});
it("returns zero on empty candidate", { timeout: 5000 }, () => {
const r = new FingerprintMatcher().matchOne(fp([[1, 0]]), fp([]));
expect(r).toEqual({ score: 0, offsetSec: 0, matchedHashCount: 0 });
});
it("scores aligned identical fingerprints highly", {
timeout: 5000,
}, () => {
const a = fp([
[10, 0.1],
[20, 0.5],
[30, 1.2],
]);
const r = new FingerprintMatcher().matchOne(a, a);
expect(r.score).toBe(3);
expect(Math.abs(r.offsetSec)).toBeLessThan(0.001);
});
it("recovers the alignment offset when candidate is the query shifted", {
timeout: 5000,
}, () => {
const q = fp([
[10, 0.1],
[20, 0.5],
[30, 1.2],
]);
const shift = 1.5;
const c = q.map((p) => ({
hash: p.hash,
timeOffsetSec: p.timeOffsetSec + shift,
}));
const r = new FingerprintMatcher().matchOne(q, c);
expect(r.score).toBe(3);
expect(Math.abs(r.offsetSec - shift)).toBeLessThan(0.011);
});
it("tracks duplicate-hash matches", { timeout: 5000 }, () => {
const q = fp([[1, 0]]);
const c = fp([
[1, 0],
[1, 5],
]);
const r = new FingerprintMatcher().matchOne(q, c);
expect(r.matchedHashCount).toBe(2);
expect(r.score).toBe(1);
});
it("throws INVALID_OPTIONS on a non-positive bin width", {
timeout: 5000,
}, () => {
const q = fp([[1, 0]]);
const c = fp([[1, 0]]);
try {
new FingerprintMatcher().matchOne(q, c, { binWidthSec: 0 });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
);
}
});
});
describe("FingerprintMatcher.match", () => {
it("returns results sorted by descending score", { timeout: 5000 }, () => {
const q = fp([
[1, 0],
[2, 0.5],
[3, 1.0],
]);
const corpus = {
values: () => [
{
id: "weak",
fingerprint: fp([[1, 0]]),
},
{
id: "strong",
fingerprint: fp([
[1, 0],
[2, 0.5],
[3, 1.0],
]),
},
{
id: "medium",
fingerprint: fp([
[1, 0],
[2, 0.5],
]),
},
],
};
const r = new FingerprintMatcher().match(q, corpus);
expect(r.map((x) => x.id)).toEqual(["strong", "medium", "weak"]);
});
it("skips candidates with no shared hashes", { timeout: 5000 }, () => {
const q = fp([[1, 0]]);
const corpus = {
values: () => [
{ id: "miss", fingerprint: fp([[99, 0]]) },
{ id: "hit", fingerprint: fp([[1, 0]]) },
],
};
const r = new FingerprintMatcher().match(q, corpus);
expect(r.map((x) => x.id)).toEqual(["hit"]);
});
it("wraps a throwing corpus in MATCH_FAILED", { timeout: 5000 }, () => {
const q = fp([[1, 0]]);
const broken = {
values: () => {
throw new Error("broken");
},
};
try {
new FingerprintMatcher().match(q, broken);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.MATCH_FAILED
);
}
});
});

View File

@@ -0,0 +1,111 @@
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Fingerprint } from "../constellation/constellation.types.js";
import type {
CorpusLike,
MatchOneResult,
MatchResult,
} from "./fingerprint-matcher.types.js";
const BIN_WIDTH_SEC = 0.01;
interface MatcherOptions {
/** Histogram bin width in seconds (default 0.01 s = 10 ms). */
readonly binWidthSec?: number;
}
/**
* Time-offset histogram matcher. For each candidate fingerprint the
* matcher builds a histogram of `(candidateOffset − queryOffset)` for
* every shared hash, then reports the heaviest bin as the match score
* and the bin midpoint as the alignment offset. Returns results sorted
* by descending score.
*/
export class FingerprintMatcher {
public match(
query: Fingerprint,
corpus: CorpusLike,
opts?: MatcherOptions
): MatchResult[] {
let entries: Iterable<{ id: string; fingerprint: Fingerprint }>;
try {
entries = corpus.values();
} catch (err) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.MATCH_FAILED,
"react-audio-fingerprint.match.iteration-failed",
{ cause: err }
);
}
const out: MatchResult[] = [];
for (const entry of entries) {
const m = this.matchOne(query, entry.fingerprint, opts);
if (m.matchedHashCount === 0) continue;
out.push({
id: entry.id,
score: m.score,
offsetSec: m.offsetSec,
matchedHashCount: m.matchedHashCount,
});
}
out.sort((a, b) => b.score - a.score);
return out;
}
/**
* Score `query` against a single `candidate`. Returns the heaviest
* histogram bucket as `score`, the bucket midpoint as `offsetSec`,
* and the count of hashes shared at all (regardless of bucket).
*/
public matchOne(
query: Fingerprint,
candidate: Fingerprint,
opts?: MatcherOptions
): MatchOneResult {
const binWidth = opts?.binWidthSec ?? BIN_WIDTH_SEC;
if (binWidth <= 0) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
"react-audio-fingerprint.match.bad-bin-width",
{ context: { binWidthSec: binWidth } }
);
}
if (query.length === 0 || candidate.length === 0) {
return { score: 0, offsetSec: 0, matchedHashCount: 0 };
}
// Index the candidate's hashes → array of times.
const index = new Map<number, number[]>();
for (const pair of candidate) {
const slot = index.get(pair.hash);
if (slot) slot.push(pair.timeOffsetSec);
else index.set(pair.hash, [pair.timeOffsetSec]);
}
const histogram = new Map<number, number>();
let matchedHashCount = 0;
for (const q of query) {
const slots = index.get(q.hash);
if (!slots) continue;
for (const t of slots) {
matchedHashCount++;
const delta = t - q.timeOffsetSec;
const bin = Math.round(delta / binWidth);
histogram.set(bin, (histogram.get(bin) ?? 0) + 1);
}
}
let bestBin = 0;
let bestCount = 0;
for (const [bin, count] of histogram) {
if (count > bestCount) {
bestCount = count;
bestBin = bin;
}
}
return {
score: bestCount,
offsetSec: bestBin * binWidth,
matchedHashCount,
};
}
}

View File

@@ -0,0 +1,23 @@
import type { Fingerprint } from "../constellation/constellation.types.js";
export interface MatchResult {
readonly id: string;
readonly score: number;
readonly offsetSec: number;
readonly matchedHashCount: number;
}
export interface MatchOneResult {
readonly score: number;
readonly offsetSec: number;
readonly matchedHashCount: number;
}
export interface FingerprintCorpusEntry {
readonly id: string;
readonly fingerprint: Fingerprint;
}
export interface CorpusLike {
values(): Iterable<FingerprintCorpusEntry>;
}

View File

@@ -0,0 +1,7 @@
export { FingerprintMatcher } from "./fingerprint-matcher.js";
export type {
CorpusLike,
FingerprintCorpusEntry,
MatchOneResult,
MatchResult,
} from "./fingerprint-matcher.types.js";

View File

@@ -0,0 +1,40 @@
export {
AudioFingerprinter,
type FingerprintOptionsInput,
FingerprintOptionsSchema,
} from "./audio-fingerprinter/audio-fingerprinter.js";
export type {
AudioFingerprinterLike,
FingerprintOptions,
FingerprintProgress,
FingerprintProgressListener,
FingerprintResult,
} from "./audio-fingerprinter/audio-fingerprinter.types.js";
export {
buildConstellation,
type ConstellationOptionsInput,
ConstellationOptionsSchema,
packHash,
unpackHash,
} from "./constellation/constellation.js";
export type {
ConstellationOptions,
Fingerprint,
FingerprintPair,
} from "./constellation/constellation.types.js";
export { FingerprintMatcher } from "./fingerprint-matcher/fingerprint-matcher.js";
export type {
CorpusLike,
FingerprintCorpusEntry,
MatchOneResult,
MatchResult,
} from "./fingerprint-matcher/fingerprint-matcher.types.js";
export {
detectPeaks,
type PeakDetectorOptionsInput,
PeakDetectorOptionsSchema,
} from "./peak-detector/peak-detector.js";
export type {
Peak,
PeakDetectorOptions,
} from "./peak-detector/peak-detector.types.js";

View File

@@ -0,0 +1,6 @@
export {
detectPeaks,
type PeakDetectorOptionsInput,
PeakDetectorOptionsSchema,
} from "./peak-detector.js";
export type { Peak, PeakDetectorOptions } from "./peak-detector.types.js";

View File

@@ -0,0 +1,69 @@
# `peak-detector` — 2D spectrogram local-peak detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The peak detector consumes a spectrogram (a sequence of FFT magnitude frames
produced by `@suaveplan/audio-analysis-spectrum`) and returns the time-frequency
coordinates of cells that dominate their local neighbourhood. The output drives
constellation hashing, which is the core of every Shazam-style fingerprint
algorithm. The detector is deterministic — identical input produces a
byte-equal peak list — and is the only place in the package where raw
spectrogram cells are inspected.
## Features
- Configurable square `neighbourhood` radius (default 3).
- `noiseFloor` threshold (linear magnitude, default 1e-3 ≈ −60 dBFS).
- `peaksPerFrame` cap (default 5) — bounds memory and downstream hash count.
- Deterministic ordering: ascending frame, then descending magnitude.
- Zod-validated options with a `ReactAudioFingerprintError(INVALID_OPTIONS)` on
invalid input.
## Usage: Basic Example
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram);
console.log(peaks.length, peaks[0]);
```
## Usage: Advanced Example
Tighten the neighbourhood and lower the floor to catch quiet harmonics in a
chirp:
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram, {
neighbourhood: 2,
noiseFloor: 1e-5,
peaksPerFrame: 8,
});
```
## API Reference
- `detectPeaks(spectrogram, opts?): Peak[]` — runs the 2D local-max sweep.
- `Peak = { frame: number; bin: number; magnitude: number }`.
- `PeakDetectorOptionsSchema` — Zod schema; defaults shown above.
- `PeakDetectorOptionsInput` — pre-parse input shape (everything optional).
## Implementation Notes
The implementation uses a straightforward `O(F · B · N²)` sweep where `F` is
the frame count, `B` is the bin count, and `N` is the neighbourhood radius. The
constants in the spec (default `N=3`, `peaksPerFrame=5`, `bins≈1025` for a
2048-point FFT) keep the cost firmly in `Float32Array` index arithmetic — no
heap allocations inside the inner loop other than the per-frame `frameHits`
array, which is bounded by the bin count. Sorting by descending magnitude and
truncating to `peaksPerFrame` ensures the loudest local maxima win when more
candidates qualify than the per-frame cap allows; ties resolve to the lower
bin via the stable native sort. Empty spectrograms produce an empty array
without throwing; downstream constellation hashing handles that case by
emitting no pairs.

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import { detectPeaks } from "./peak-detector.js";
function makeSpectrogram(
frames: number,
bins: number,
hot: ReadonlyArray<[number, number, number]> = []
): Float32Array[] {
const out: Float32Array[] = [];
for (let f = 0; f < frames; f++) out.push(new Float32Array(bins));
for (const [f, b, v] of hot) out[f]![b] = v;
return out;
}
describe("detectPeaks", () => {
it("returns an empty array on an empty spectrogram", {
timeout: 5000,
}, () => {
expect(detectPeaks([])).toEqual([]);
});
it("finds an isolated peak above the noise floor", {
timeout: 5000,
}, () => {
const spec = makeSpectrogram(10, 20, [[5, 10, 1.0]]);
const peaks = detectPeaks(spec, { neighbourhood: 2, noiseFloor: 0.5 });
expect(peaks).toEqual([{ frame: 5, bin: 10, magnitude: 1.0 }]);
});
it("ignores cells below the noise floor", { timeout: 5000 }, () => {
const spec = makeSpectrogram(5, 10, [[2, 5, 0.001]]);
const peaks = detectPeaks(spec, { noiseFloor: 0.5 });
expect(peaks).toEqual([]);
});
it("respects peaksPerFrame cap (loudest first)", { timeout: 5000 }, () => {
// Three candidates in one frame; cap at 2 → keep the two largest.
const spec = makeSpectrogram(1, 30, [
[0, 1, 0.7],
[0, 10, 0.9],
[0, 20, 0.8],
]);
const peaks = detectPeaks(spec, { neighbourhood: 1, peaksPerFrame: 2 });
expect(peaks.map((p) => p.bin)).toEqual([10, 20]);
});
it("treats a larger neighbour as the winner (no peak emitted at the lesser cell)", {
timeout: 5000,
}, () => {
const spec = makeSpectrogram(3, 3, [
[1, 1, 1.0],
[1, 2, 2.0],
]);
const peaks = detectPeaks(spec, { neighbourhood: 1, noiseFloor: 0.5 });
// Only the larger neighbour at bin=2 is a peak.
expect(peaks).toEqual([{ frame: 1, bin: 2, magnitude: 2.0 }]);
});
it("throws REACT_AUDIO_FINGERPRINT.INVALID_OPTIONS on bad input", {
timeout: 5000,
}, () => {
try {
detectPeaks([new Float32Array(8)], {
neighbourhood: -1,
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
expect((e as ReactAudioFingerprintError).code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
);
}
});
it("is deterministic across runs", { timeout: 5000 }, () => {
const spec = makeSpectrogram(8, 12, [
[1, 1, 0.6],
[4, 5, 0.9],
[6, 9, 0.7],
]);
const a = detectPeaks(spec, { neighbourhood: 2, noiseFloor: 0.5 });
const b = detectPeaks(spec, { neighbourhood: 2, noiseFloor: 0.5 });
expect(a).toEqual(b);
});
it("applies defaults when no options are supplied", {
timeout: 5000,
}, () => {
const spec = makeSpectrogram(5, 5, [[2, 2, 1.0]]);
const peaks = detectPeaks(spec);
expect(peaks.length).toBe(1);
});
});

View File

@@ -0,0 +1,83 @@
import z from "zod";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Peak, PeakDetectorOptions } from "./peak-detector.types.js";
/**
* Zod schema for peak-detector options. Defaults reflect the classic
* Shazam constellation paper: a 3-cell-radius neighbourhood, a −60 dBFS
* (=> 1e-3 linear) noise floor, and at most 5 peaks per frame.
*/
export const PeakDetectorOptionsSchema = z.object({
neighbourhood: z.number().int().positive().default(3),
noiseFloor: z.number().nonnegative().default(1e-3),
peaksPerFrame: z.number().int().positive().default(5),
});
export type PeakDetectorOptionsInput = z.input<
typeof PeakDetectorOptionsSchema
>;
function parseOptions(raw?: PeakDetectorOptionsInput): PeakDetectorOptions {
const result = PeakDetectorOptionsSchema.safeParse(raw ?? {});
if (!result.success) {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
"react-audio-fingerprint.peak-detector.invalid-options",
{ context: { issues: result.error.issues } }
);
}
return result.data;
}
/**
* Detect 2D local-maxima peaks in a spectrogram. For each frame the
* function emits up to `peaksPerFrame` peaks: a cell is a peak iff its
* magnitude is ≥ all cells within the `neighbourhood`-sized box that
* lies within bounds AND ≥ `noiseFloor`. Output is sorted by ascending
* `frame` and then descending `magnitude` within a frame, giving callers
* a deterministic ordering for downstream hashing.
*/
export function detectPeaks(
spectrogram: ReadonlyArray<Float32Array>,
raw?: PeakDetectorOptionsInput
): Peak[] {
const opts = parseOptions(raw);
const peaks: Peak[] = [];
const frames = spectrogram.length;
if (frames === 0) return peaks;
const bins = spectrogram[0]!.length;
const N = opts.neighbourhood;
for (let f = 0; f < frames; f++) {
const frameData = spectrogram[f]!;
const frameHits: Peak[] = [];
for (let b = 0; b < bins; b++) {
const v = frameData[b]!;
if (v < opts.noiseFloor) continue;
let isPeak = true;
const fLo = Math.max(0, f - N);
const fHi = Math.min(frames - 1, f + N);
const bLo = Math.max(0, b - N);
const bHi = Math.min(bins - 1, b + N);
for (let nf = fLo; nf <= fHi && isPeak; nf++) {
const row = spectrogram[nf]!;
for (let nb = bLo; nb <= bHi; nb++) {
if (nf === f && nb === b) continue;
if (row[nb]! > v) {
isPeak = false;
break;
}
}
}
if (isPeak) frameHits.push({ frame: f, bin: b, magnitude: v });
}
frameHits.sort((a, b) => b.magnitude - a.magnitude);
for (let i = 0; i < frameHits.length && i < opts.peaksPerFrame; i++) {
peaks.push(frameHits[i]!);
}
}
return peaks;
}

View File

@@ -0,0 +1,23 @@
/**
* Detected peak in a single spectrogram frame.
*
* - `frame` — frame index (0-based, time axis).
* - `bin` — frequency-bin index (0-based, frequency axis).
* - `magnitude` — linear magnitude reported by the spectrogram for this cell.
*/
export interface Peak {
readonly frame: number;
readonly bin: number;
readonly magnitude: number;
}
/**
* Tunables for the 2D peak detector. All fields are required after Zod
* parse — callers normally use the schema defaults rather than passing
* raw structures.
*/
export interface PeakDetectorOptions {
readonly neighbourhood: number;
readonly noiseFloor: number;
readonly peaksPerFrame: number;
}

View File

@@ -0,0 +1,7 @@
export { useAudioFingerprint } from "./use-audio-fingerprint/use-audio-fingerprint.js";
export type {
FingerprintState,
UseAudioFingerprintOptions,
UseAudioFingerprintResult,
UseAudioFingerprintSource,
} from "./use-audio-fingerprint/use-audio-fingerprint.types.js";

View File

@@ -0,0 +1,7 @@
export { useAudioFingerprint } from "./use-audio-fingerprint.js";
export type {
FingerprintState,
UseAudioFingerprintOptions,
UseAudioFingerprintResult,
UseAudioFingerprintSource,
} from "./use-audio-fingerprint.types.js";

View File

@@ -0,0 +1,71 @@
# `useAudioFingerprint` — React hook for the fingerprint pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a
React-managed `AudioDocument` and returns the live state, the resulting
fingerprint when done, a `0..1` progress fraction, and any error encountered.
The hook centralises the abort-on-unmount and progress wiring so component
authors do not have to repeat that work across uploaders, visualisers, and
match displays.
## Features
- `source` can be `undefined`/`null` to mean "do nothing" — useful while
waiting for an upload to decode.
- `state` cycles through `'idle'` → `'fingerprinting'` → `'done'` (or
`'error'`); a fresh source resets the cycle.
- Abort-on-unmount and abort-on-source-change via internal `AbortController`.
- Backend swap via `options.backend`; defaults to `LocalBackend`.
- Wraps non-`ReactAudioFingerprintError` rejections in `FINGERPRINT_FAILED`.
- Suppresses `ABORTED` errors — they settle back to `'idle'`.
## Usage: Basic Example
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function FingerprintPreview({ doc }: { doc: AudioDocument }) {
const { state, fingerprint, progress } = useAudioFingerprint(doc);
if (state === "fingerprinting") return <progress value={progress} />;
if (state === "done") return <span>{fingerprint?.length} pairs</span>;
return null;
}
```
## Usage: Advanced Example
Inject a custom backend (e.g. the optional Python backend) and surface errors:
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function Surface({ doc, backend }: { doc: AudioDocument; backend: FingerprintBackend }) {
const { state, error, fingerprint } = useAudioFingerprint(doc, { backend });
if (state === "error") return <Banner code={error?.code}>{error?.message}</Banner>;
if (state === "done") return <Result fp={fingerprint!} />;
return <Spinner />;
}
```
## API Reference
- `useAudioFingerprint(source, options?): UseAudioFingerprintResult`.
- `source: AudioDocument | undefined | null`.
- `options?: { backend?: FingerprintBackend }`.
- `UseAudioFingerprintResult = { state, fingerprint, progress, error }`.
- `state: 'idle' | 'fingerprinting' | 'done' | 'error'`.
## Implementation Notes
The hook uses `useMemo` to stabilise the default `LocalBackend` so changing
unrelated props does not retrigger the effect. The `alive` flag guarding all
post-await `setState` calls is the canonical React pattern for protecting
against the lifecycle race between an async fetch completing and a fast
unmount. Aborts settle to `'idle'` (not `'error'`) so user-driven cancels do
not produce a banner. Non-Suaveplan errors get wrapped so consumers can always
discriminate via `error.code` without an `instanceof` chain.

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import { makeDoc, twoTone } from "../../../tests/fixtures.js";
import type { FingerprintBackend } from "../../backends/local-backend/local-backend.types.js";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import { useAudioFingerprint } from "./use-audio-fingerprint.js";
function makeBackend(behaviour: {
delayMs?: number;
fail?: "code" | "raw" | "abort";
}): FingerprintBackend {
return {
async fingerprint(_doc, opts) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
if (opts?.signal?.aborted) {
reject(
new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
"test.abort"
)
);
return;
}
opts?.onProgress?.({ fraction: 0.5 });
resolve();
}, behaviour.delayMs ?? 10);
opts?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(
new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
"test.abort"
)
);
});
});
if (behaviour.fail === "code") {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED,
"test.code"
);
}
if (behaviour.fail === "raw") {
throw new Error("plain failure");
}
opts?.onProgress?.({ fraction: 1 });
return [{ hash: 1, timeOffsetSec: 0 }];
},
async match() {
return [];
},
};
}
describe("useAudioFingerprint", () => {
it("settles in idle when source is undefined", { timeout: 5000 }, () => {
const { result } = renderHook(() => useAudioFingerprint(undefined));
expect(result.current.state).toBe("idle");
expect(result.current.fingerprint).toBeUndefined();
expect(result.current.progress).toBe(0);
});
it("advances to done with a fingerprint", { timeout: 5000 }, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
const backend = makeBackend({});
const { result } = renderHook(() =>
useAudioFingerprint(doc, { backend })
);
await waitFor(() => expect(result.current.state).toBe("done"), {
timeout: 4000,
});
expect(result.current.fingerprint?.length).toBe(1);
expect(result.current.progress).toBe(1);
});
it("surfaces ReactAudioFingerprintError on failure", {
timeout: 5000,
}, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
const backend = makeBackend({ fail: "code" });
const { result } = renderHook(() =>
useAudioFingerprint(doc, { backend })
);
await waitFor(() => expect(result.current.state).toBe("error"), {
timeout: 4000,
});
expect(result.current.error?.code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
);
});
it("wraps non-suaveplan errors in FINGERPRINT_FAILED", {
timeout: 5000,
}, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
const backend = makeBackend({ fail: "raw" });
const { result } = renderHook(() =>
useAudioFingerprint(doc, { backend })
);
await waitFor(() => expect(result.current.state).toBe("error"), {
timeout: 4000,
});
expect(result.current.error?.code).toBe(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
);
});
it("settles to idle when backend rejects with ABORTED", {
timeout: 5000,
}, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
const backend: FingerprintBackend = {
async fingerprint() {
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
"test.abort-direct"
);
},
async match() {
return [];
},
};
const { result } = renderHook(() =>
useAudioFingerprint(doc, { backend })
);
await waitFor(() => expect(result.current.state).toBe("idle"));
expect(result.current.progress).toBe(0);
expect(result.current.fingerprint).toBeUndefined();
});
it("settles to idle on unmount-triggered abort", {
timeout: 5000,
}, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
const backend = makeBackend({ delayMs: 200 });
const { result, unmount } = renderHook(() =>
useAudioFingerprint(doc, { backend })
);
await waitFor(() =>
expect(result.current.state).toBe("fingerprinting")
);
await act(async () => {
unmount();
await new Promise((r) => setTimeout(r, 50));
});
// No additional asserts — the test passes if no error is thrown
// and `alive` correctly suppresses post-unmount state updates.
expect(true).toBe(true);
});
it("suppresses progress + resolution after unmount", {
timeout: 5000,
}, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
// Backend that ignores the signal: it resolves after a delay
// and emits a progress event on the way through. We unmount
// before the resolution fires, exercising both the
// `if (alive)` and `if (!alive) return;` false-paths.
const backend: FingerprintBackend = {
async fingerprint(_doc, opts) {
await new Promise((r) => setTimeout(r, 80));
opts?.onProgress?.({ fraction: 0.5 });
await new Promise((r) => setTimeout(r, 20));
return [{ hash: 7, timeOffsetSec: 0 }];
},
async match() {
return [];
},
};
const { unmount } = renderHook(() =>
useAudioFingerprint(doc, { backend })
);
unmount();
// Wait long enough for both the progress callback and the
// resolution to fire on the dead closure.
await new Promise((r) => setTimeout(r, 200));
// Nothing to assert beyond "no crash" — the alive guards
// suppress all state updates.
expect(true).toBe(true);
});
it("resets to idle when source becomes null after success", {
timeout: 5000,
}, async () => {
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
const backend = makeBackend({});
const { result, rerender } = renderHook(
({ src }: { src: typeof doc | null }) =>
useAudioFingerprint(src, { backend }),
{ initialProps: { src: doc } }
);
await waitFor(() => expect(result.current.state).toBe("done"));
rerender({ src: null });
await waitFor(() => expect(result.current.state).toBe("idle"));
expect(result.current.fingerprint).toBeUndefined();
});
it("defaults to LocalBackend when no backend prop is passed", {
timeout: 30000,
}, async () => {
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
const { result } = renderHook(() => useAudioFingerprint(doc));
await waitFor(() => expect(result.current.state).toBe("done"), {
timeout: 25000,
});
expect(result.current.fingerprint).toBeDefined();
});
});

View File

@@ -0,0 +1,102 @@
import { useEffect, useMemo, useState } from "react";
import { LocalBackend } from "../../backends/local-backend/local-backend.js";
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "../../error-codes/error-codes.js";
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
import type {
FingerprintState,
UseAudioFingerprintOptions,
UseAudioFingerprintResult,
UseAudioFingerprintSource,
} from "./use-audio-fingerprint.types.js";
/**
* Drive a fingerprint computation off a React-managed source.
*
* Lifecycle:
* - `source == null` → `state = 'idle'`, `fingerprint = undefined`.
* - `source` provided → kicks the backend, advances through
* `'fingerprinting'` (progress 0..1) to `'done'` with the result.
* - Unmount or source change aborts the in-flight task via the
* `AbortController` created internally; the abandoned promise's
* state-mutations are suppressed by an `alive` flag.
*
* SSR-safe: the initial render returns
* `{ state: 'idle', fingerprint: undefined, progress: 0, error: undefined }`
* without touching the backend.
*/
export function useAudioFingerprint(
source: UseAudioFingerprintSource,
options?: UseAudioFingerprintOptions
): UseAudioFingerprintResult {
const backend = useMemo(
() => options?.backend ?? new LocalBackend(),
[options?.backend]
);
const [state, setState] = useState<FingerprintState>("idle");
const [fingerprint, setFingerprint] = useState<Fingerprint | undefined>(
undefined
);
const [progress, setProgress] = useState<number>(0);
const [error, setError] = useState<ReactAudioFingerprintError | undefined>(
undefined
);
useEffect(() => {
if (!source) {
setState("idle");
setFingerprint(undefined);
setProgress(0);
setError(undefined);
return;
}
let alive = true;
const controller = new AbortController();
setState("fingerprinting");
setFingerprint(undefined);
setProgress(0);
setError(undefined);
backend
.fingerprint(source, {
signal: controller.signal,
onProgress: ({ fraction }) => {
if (alive) setProgress(fraction);
},
})
.then((fp) => {
if (!alive) return;
setFingerprint(fp);
setProgress(1);
setState("done");
})
.catch((e) => {
if (!alive) return;
if (
e instanceof ReactAudioFingerprintError &&
e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED
) {
setState("idle");
setProgress(0);
return;
}
setError(
e instanceof ReactAudioFingerprintError
? e
: new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED,
"react-audio-fingerprint.hook.unexpected",
{ cause: e }
)
);
setState("error");
});
return () => {
alive = false;
controller.abort();
};
}, [source, backend]);
return { state, fingerprint, progress, error };
}

View File

@@ -0,0 +1,19 @@
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
import type { FingerprintBackend } from "../../backends/local-backend/local-backend.types.js";
import type { ReactAudioFingerprintError } from "../../error-codes/error-codes.js";
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
export type FingerprintState = "idle" | "fingerprinting" | "done" | "error";
export interface UseAudioFingerprintResult {
readonly state: FingerprintState;
readonly fingerprint: Fingerprint | undefined;
readonly progress: number;
readonly error: ReactAudioFingerprintError | undefined;
}
export interface UseAudioFingerprintOptions {
readonly backend?: FingerprintBackend;
}
export type UseAudioFingerprintSource = AudioDocument | undefined | null;

View File

@@ -0,0 +1,63 @@
export {
type FingerprintBackend,
LocalBackend,
} from "./backends/local-backend/index.js";
export { FingerprintMatchDisplay } from "./components/fingerprint-match-display/fingerprint-match-display.js";
export type { FingerprintMatchDisplayProps } from "./components/fingerprint-match-display/fingerprint-match-display.types.js";
export { MemoryCorpus } from "./corpus/memory-corpus/memory-corpus.js";
export type { Corpus } from "./corpus/memory-corpus/memory-corpus.types.js";
export {
REACT_AUDIO_FINGERPRINT_CODES,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
ReactAudioFingerprintError,
type ReactAudioFingerprintErrorCode,
type ReactAudioFingerprintErrorMetadata,
registerReactAudioFingerprintErrorCodes,
} from "./error-codes/error-codes.js";
export {
AudioFingerprinter,
type FingerprintOptionsInput,
FingerprintOptionsSchema,
} from "./fingerprint/audio-fingerprinter/audio-fingerprinter.js";
export type {
AudioFingerprinterLike,
FingerprintOptions,
FingerprintProgress,
FingerprintProgressListener,
FingerprintResult,
} from "./fingerprint/audio-fingerprinter/audio-fingerprinter.types.js";
export {
buildConstellation,
type ConstellationOptionsInput,
ConstellationOptionsSchema,
packHash,
unpackHash,
} from "./fingerprint/constellation/constellation.js";
export type {
ConstellationOptions,
Fingerprint,
FingerprintPair,
} from "./fingerprint/constellation/constellation.types.js";
export { FingerprintMatcher } from "./fingerprint/fingerprint-matcher/fingerprint-matcher.js";
export type {
CorpusLike,
FingerprintCorpusEntry,
MatchOneResult,
MatchResult,
} from "./fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
export {
detectPeaks,
type PeakDetectorOptionsInput,
PeakDetectorOptionsSchema,
} from "./fingerprint/peak-detector/peak-detector.js";
export type {
Peak,
PeakDetectorOptions,
} from "./fingerprint/peak-detector/peak-detector.types.js";
export { useAudioFingerprint } from "./hooks/use-audio-fingerprint/use-audio-fingerprint.js";
export type {
FingerprintState,
UseAudioFingerprintOptions,
UseAudioFingerprintResult,
UseAudioFingerprintSource,
} from "./hooks/use-audio-fingerprint/use-audio-fingerprint.types.js";

View File

@@ -0,0 +1,58 @@
# `test-setup` — Vitest global setup
## Purpose
This file is loaded by `vitest.config.ts` via `setupFiles` so every test in
the package runs against a consistent jest-dom matcher set and tears down its
React tree after each case. It is intentionally short — heavy work happens in
the runner-agnostic polyfills shipped by `@suaveplan/testing/polyfills`, which
this file complements with DOM-specific affordances.
## Features
- Imports `@testing-library/jest-dom/vitest` to register matchers such as
`.toBeInTheDocument()` and `.toHaveAccessibleName()`.
- Registers an `afterEach` that calls `cleanup()` from
`@testing-library/react`, unmounting trees between tests and avoiding
cross-test DOM pollution.
- No mocks, no spies, no global stubs — keeps the suite faithful to the
CLAUDE.md "no mocks" rule.
## Usage: Basic Example
`vitest.config.ts` references this file:
```ts
setupFiles: [
"@suaveplan/testing/polyfills",
"./src/test-setup.ts",
],
```
## Usage: Advanced Example
If a future component needs an additional matcher (for example to assert
custom accessibility properties), add the relevant import here so every test
in the package benefits without per-file boilerplate:
```ts
import "@testing-library/jest-dom/vitest";
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
// (Add additional matchers or global afterEach hooks here.)
afterEach(() => cleanup());
```
## API Reference
- No exports; runs purely for side-effects at vitest setup time.
- Depends on `@suaveplan/testing/runner` for `afterEach` (rule 11 compliance).
## Implementation Notes
The setup file runs once per worker, so the `cleanup` afterEach registration
is shared across every test file in that worker. This is the canonical
pattern documented by Testing Library and matches every sibling Suaveplan
React package's setup. The package owns no provider configuration so there is
no provider-tree wrapper to install — tests render components directly via
`@testing-library/react`'s `render` / `renderHook`.

View File

@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,61 @@
import { AudioDocument } from "@suaveplan/audio-core/document/document";
const PAGE_SIZE = 1024 * 1024;
/** Build an `AudioDocument` from a flat mono `Float32Array`. */
export function makeDoc(
samples: Float32Array,
sampleRate = 48_000
): AudioDocument {
const pages: { channelData: Float32Array[]; startSample: number }[] = [];
let offset = 0;
while (offset < samples.length) {
const end = Math.min(offset + PAGE_SIZE, samples.length);
pages.push({
channelData: [samples.slice(offset, end)],
startSample: offset,
});
offset = end;
}
if (pages.length === 0) {
pages.push({ channelData: [new Float32Array(0)], startSample: 0 });
}
return new AudioDocument({
sampleRate,
channels: 1,
sourceLength: samples.length,
pages,
});
}
/** Generate a sine-wave Float32Array. */
export function sineWave(
durationSec: number,
freqHz: number,
sampleRate = 48_000
): Float32Array {
const n = Math.floor(durationSec * sampleRate);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
out[i] = Math.sin((2 * Math.PI * freqHz * i) / sampleRate);
}
return out;
}
/** Sum of two sines (lets the peak detector find more than one frequency). */
export function twoTone(
durationSec: number,
f1: number,
f2: number,
sampleRate = 48_000
): Float32Array {
const n = Math.floor(durationSec * sampleRate);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
const t = i / sampleRate;
out[i] =
0.5 * Math.sin(2 * Math.PI * f1 * t) +
0.5 * Math.sin(2 * Math.PI * f2 * t);
}
return out;
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../foundation/core/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,73 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/react-audio-fingerprint",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"fingerprint/index": "src/fingerprint/index.ts",
"fingerprint/peak-detector/index":
"src/fingerprint/peak-detector/index.ts",
"fingerprint/constellation/index":
"src/fingerprint/constellation/index.ts",
"fingerprint/audio-fingerprinter/index":
"src/fingerprint/audio-fingerprinter/index.ts",
"fingerprint/fingerprint-matcher/index":
"src/fingerprint/fingerprint-matcher/index.ts",
"corpus/index": "src/corpus/index.ts",
"corpus/memory-corpus/index":
"src/corpus/memory-corpus/index.ts",
"backends/index": "src/backends/index.ts",
"backends/local-backend/index":
"src/backends/local-backend/index.ts",
"hooks/index": "src/hooks/index.ts",
"hooks/use-audio-fingerprint/index":
"src/hooks/use-audio-fingerprint/index.ts",
"components/index": "src/components/index.ts",
"components/fingerprint-match-display/index":
"src/components/fingerprint-match-display/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/audio-analysis-spectrum",
"@suaveplan/audio-core",
"@suaveplan/audio-core/document/document",
"@suaveplan/audio-engine-browser",
"@suaveplan/audio-engine-browser/decode",
"@suaveplan/error",
"@suaveplan/types",
"react",
"react-dom",
"react/jsx-runtime",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,54 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
/**
* Standalone Vitest config — bypasses `createTestConfig` because we need the
* `@vitejs/plugin-react` plugin to transform `.tsx` test + source files
* (JSX runtime). Mirrors the canonical genesis shape (forks pool, polyfills
* setup, istanbul 100% thresholds) as documented in CLAUDE.md.
*/
export default defineConfig({
plugins: [react()],
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
fileParallelism: false,
setupFiles: [
"@suaveplan/testing/polyfills",
"./src/test-setup.ts",
],
include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"tests/**/*.test.ts",
"tests/**/*.test.tsx",
],
exclude: ["dist/**", "node_modules/**", "src/__benchmarks__/**"],
testTimeout: 30000,
hookTimeout: 30000,
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.stories.tsx",
"src/__benchmarks__/**",
"src/test-setup.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
});