# Capability Spec Delta: react-audio-fingerprint (enterprise-react-audio-fingerprint) **Package**: `@suaveplan/react-audio-fingerprint` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Fingerprint-tier purity — no FFT, no audio context `@suaveplan/react-audio-fingerprint` SHALL NOT implement its own FFT or spectrogram — those are delegated to `@suaveplan/audio-analysis-spectrum`. The package SHALL NOT reference `AudioContext`, `OfflineAudioContext`, `AudioNode`, or `AudioWorklet`. The package's runtime `dependencies` SHALL be limited to `@suaveplan/react-audio`, `@suaveplan/audio-analysis-spectrum`, `@suaveplan/audio-engine-browser`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events`, `zustand`, `framer-motion`, `lucide-react`, `zod`, `tslib`. `react` and `@suaveplan/python-audio-fingerprint` SHALL be peer dependencies; the latter SHALL only be required by the `./backends/python-backend` subpath. #### Scenario: built dist contains no FFT implementation - **WHEN** the source tree is grep'd for FFT-implementation hallmarks (Cooley-Tukey, twiddle-factor tables, radix-2/4 butterfly inlines) inside `src/fingerprint/` - **THEN** no implementation is present; the spectrogram is reached only through imports of `@suaveplan/audio-analysis-spectrum` **Verified by:** `tests/purity.test.ts::"no FFT is implemented in src/; spectrogram is reached via audio-analysis-spectrum"` AND `tests/purity.test.ts::"runtime dependency set matches the allowlist"` --- ### Requirement: AudioFingerprinter constellation algorithm The package SHALL export `AudioFingerprinter` from `./fingerprint/audio-fingerprinter` whose `fingerprint(source: AudioBuffer, opts?): Promise` SHALL: (1) compute the spectrogram via `audio-analysis-spectrum` using a default 4096-point Hann window with 75 % overlap (configurable), (2) detect 2D local peaks per frame above a `noiseFloorDb` (default −60 dBFS), capped at `peaksPerFrame` (default 5), (3) for each peak emit constellation pair hashes against the next `fanout` (default 5) peaks within `targetZoneSec` (default 5.0), and (4) return a sorted-by-time `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`. Output SHALL be deterministic: identical input bytes produce a byte-equal fingerprint. #### Scenario: identical input produces identical fingerprint - **WHEN** the same `AudioBuffer` is fingerprinted twice - **THEN** the two fingerprints are byte-equal #### Scenario: noise-tolerant partial match - **WHEN** a 10-second clip is extracted from a 30-second source, white-noised at −20 dB SNR, and fingerprinted - **THEN** `FingerprintMatcher.matchOne(clipFingerprint, fullFingerprint)` returns a score whose histogram peak is ≥ 30 % of `clip.length`, and the `offsetSec` matches the clip's position within ±50 ms **Verified by:** `src/fingerprint/audio-fingerprinter.test.ts::"identical input produces identical fingerprint"` AND `tests/property.test.ts::"noisy clip matches its source within histogram tolerance"` --- ### Requirement: FingerprintMatcher time-offset histogram The package SHALL export `FingerprintMatcher` from `./fingerprint/fingerprint-matcher` whose `match(query, corpus): MatchResult[]` SHALL use time-offset histogramming (10 ms bin width) to score each candidate, and return results sorted by descending score. `matchOne(query, candidate): { score: number; offsetSec: number }` SHALL return the maximum bucket count and the offset of that bucket. `MatchResult` SHALL carry `{ id: string; score: number; offsetSec: number; matchedHashCount: number }`. #### Scenario: match returns results sorted by descending score - **WHEN** `match(query, corpusOfThree)` is called against fingerprints with known descending similarity to the query - **THEN** the result array is ordered by descending `score` and `length === 3` **Verified by:** `src/fingerprint/fingerprint-matcher.test.ts::"match returns results sorted by descending score"` --- ### Requirement: useAudioFingerprint hook `useAudioFingerprint(source, opts?)` SHALL return `{ fingerprint, state, error, progress }` where `state ∈ 'idle' | 'decoding' | 'fingerprinting' | 'done' | 'error'`, `progress` is a `0..1` float updated at frame rate via `useSyncExternalStore`, and `error` is a `ReactAudioFingerprintError` when state is `'error'`. The hook SHALL accept `opts.signal: AbortSignal`; on unmount or signal abort, the in-flight fingerprint SHALL be cancelled and `state` SHALL settle to `'idle'`. The hook SHALL be SSR-safe: `state === 'idle'` and `fingerprint === undefined` on the first server render. #### Scenario: unmount cancels in-flight fingerprint - **WHEN** the consumer component using `useAudioFingerprint(longSource)` unmounts while `state === 'fingerprinting'` - **THEN** the underlying fingerprint task's `AbortSignal` fires, no further state updates occur, and no error is logged #### Scenario: progress advances monotonically to 1 - **WHEN** a 30-second fingerprint runs to completion - **THEN** `progress` increases monotonically from 0 toward 1 and reaches `1.0` exactly when `state` transitions to `'done'` **Verified by:** `src/hooks/use-audio-fingerprint/use-audio-fingerprint.test.tsx::"unmount cancels in-flight fingerprint"` AND `src/hooks/use-audio-fingerprint/use-audio-fingerprint.test.tsx::"progress advances monotonically to 1"` --- ### Requirement: useFingerprintMatcher with corpus subscription `useFingerprintMatcher(corpus, opts?)` SHALL return `{ match(query), matching, lastResults }`. The hook SHALL subscribe to the corpus's `'changed'` event so adding / removing entries between matches updates the relevant render. `lastResults` SHALL be cleared when the corpus identity changes (different `Corpus` instance prop) but SHALL persist across `add`/`remove` of the same corpus. #### Scenario: corpus add re-renders consumers that read size or lastResults relevance - **WHEN** a consumer reading `useFingerprintMatcher(corpus)` triggers `corpus.add(id, fingerprint)` - **THEN** the consumer re-renders once with the corpus change visible **Verified by:** `src/hooks/use-fingerprint-matcher/use-fingerprint-matcher.test.tsx::"corpus add re-renders consumers"` --- ### Requirement: Backend abstraction The package SHALL export a `FingerprintBackend` interface with `fingerprint(source): Promise` and `match(query, corpusRef): Promise`. A `LocalBackend` (default, in-browser algorithm) SHALL be the default; a `PythonBackend` (optional, gated to `./backends/python-backend` subpath) SHALL delegate to `@suaveplan/python-audio-fingerprint` over HTTP. Hooks SHALL accept `opts.backend?: FingerprintBackend` and SHALL default to `LocalBackend` when absent. #### Scenario: hook honours injected backend - **WHEN** `useAudioFingerprint(source, { backend: customBackend })` runs - **THEN** `customBackend.fingerprint` is called and the local algorithm is not exercised #### Scenario: python-backend is gated to its subpath - **WHEN** a consumer imports the package root without `./backends/python-backend` - **THEN** the tree-shaken bundle contains zero bytes from `@suaveplan/python-audio-fingerprint` **Verified by:** `src/hooks/use-audio-fingerprint/use-audio-fingerprint.test.tsx::"hook honours injected backend"` AND `tests/bundle-size/root-only.size.test.ts::"root barrel does not bundle python-audio-fingerprint"` --- ### Requirement: Corpus implementations The package SHALL export `MemoryCorpus` (`Map`-backed, synchronous wrapped in `Promise`) and `IndexedDbCorpus` (IndexedDB-backed, persistent across page reloads) from `./corpus/memory-corpus` and `./corpus/indexeddb-corpus`. Both SHALL implement the `Corpus` interface (`add`, `remove`, `get`, `values`, `size`) and emit a typed `'changed'` event on mutation. `IndexedDbCorpus.add` SHALL persist the fingerprint such that a fresh `IndexedDbCorpus` instance constructed in a later session can read it back. Custom corpus implementations are first-class consumers; nothing in the package presumes which corpus is in use. #### Scenario: IndexedDb corpus persists across instances - **WHEN** a caller adds an entry via one `IndexedDbCorpus` instance, then constructs a fresh instance with the same `dbName`/`storeName` - **THEN** the second instance's `get(id)` returns the same fingerprint **Verified by:** `src/corpus/indexeddb-corpus/indexeddb-corpus.test.ts::"persists across instances with the same dbName"` --- ### Requirement: AudioFingerprintUploader component `` SHALL accept input via file drop, file picker (click), and URL paste, decode the source via `audio-engine-browser`'s decode path, run the fingerprint, and call `onFingerprint(fp)` on success. Errors SHALL surface as an accessible toast carrying the `ReactAudioFingerprintError`'s `code`. The component SHALL render a progress bar driven by `useAudioFingerprint.progress` and SHALL be keyboard-accessible (Enter / Space to open the file picker, `role="button"` on the drop zone, `aria-busy` while fingerprinting). #### Scenario: drop file → fingerprint → onFingerprint fires - **WHEN** a user drops an audio file onto `` - **THEN** the component decodes, fingerprints, and calls `onFingerprint(fp)` exactly once with the resulting fingerprint **Verified by:** `src/components/audio-fingerprint-uploader/audio-fingerprint-uploader.test.tsx::"drop file calls onFingerprint once"` --- ### Requirement: FingerprintVisualizer constellation overlay `` SHALL render a `` displaying the input spectrogram (via `audio-analysis-spectrum`'s spectrogram render path) overlaid with the constellation peaks from `fingerprint`. When `spectrogram` is not supplied, the component SHALL render only the constellation (sparse plot). Hovering or focusing a peak SHALL show its `{ frequencyHz, timeSec, hash }` in a tooltip / aria-described region. #### Scenario: peaks render at the expected canvas coordinates - **WHEN** a fingerprint with a known peak at `(timeSec: 1.5, frequencyHz: 440)` is rendered against a 1024×512 canvas spanning 0..3 s × 0..22050 Hz - **THEN** the rendered peak's canvas coordinates are within ±2 px of `(512, 502)` **Verified by:** `src/components/fingerprint-visualizer/fingerprint-visualizer.test.tsx::"peaks render at the expected canvas coordinates"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL register: a histogram `react_audio_fingerprint_fingerprint_duration_ms{backend}`, a histogram `react_audio_fingerprint_match_duration_ms{backend}`, and a gauge `react_audio_fingerprint_corpus_size{corpus}`. Instrumentation SHALL be inert when no exporter is registered. #### Scenario: corpus size gauge tracks add and remove - **WHEN** five entries are added to a corpus then two removed - **THEN** `react_audio_fingerprint_corpus_size` reads 3 **Verified by:** `src/corpus/memory-corpus/memory-corpus.test.ts::"OTel corpus size gauge tracks add and remove"` --- ### Requirement: Structured error codes The package SHALL declare its codes via `defineCodes("REACT_AUDIO_FINGERPRINT", [...])` producing the dotted codes `REACT_AUDIO_FINGERPRINT.DECODE_FAILED`, `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`, `REACT_AUDIO_FINGERPRINT.MATCH_FAILED`, `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED`, `REACT_AUDIO_FINGERPRINT.ABORTED`, and `REACT_AUDIO_FINGERPRINT.INVALID_OPTIONS`. Export `ReactAudioFingerprintErrorCode`, `REACT_AUDIO_FINGERPRINT_ERROR_METADATA`, `registerReactAudioFingerprintErrorCodes()`, and a `ReactAudioFingerprintError` class extending `AudioError`. Every registered code SHALL be reachable from a real call site. #### Scenario: every registered code is reachable - **WHEN** the test suite runs - **THEN** each code in `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` is asserted thrown by at least one test **Verified by:** `src/error-codes.test.ts::"every REACT_AUDIO_FINGERPRINT code is reachable from a call site"` --- ### Requirement: Subpath exports per surface `package.json` SHALL declare one `exports` entry per surface: `.`, `./fingerprint`, `./hooks`, `./hooks/`, `./components`, `./components/`, `./corpus`, `./corpus/`, `./backends`, `./backends/local-backend`, `./backends/python-backend`. `sideEffects` SHALL be `false`. #### Scenario: importing the matcher does not pull in components - **WHEN** a consumer imports `@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher` - **THEN** the tree-shaken bundle contains zero bytes from `src/components/` **Verified by:** `tests/exports.test.ts::"every surface has a subpath export entry"` AND `tests/bundle-size/matcher-only.size.test.ts::"matcher subpath does not bundle components"`