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