Files
2026-05-27 23:29:53 +01:00

13 KiB
Raw Permalink Blame History

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<Fingerprint> 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<Fingerprint> and match(query, corpusRef): Promise<MatchResult[]>. 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

<AudioFingerprintUploader onFingerprint label?> 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 <AudioFingerprintUploader>
  • 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

<FingerprintVisualizer fingerprint spectrogram?> SHALL render a <canvas> 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/<name>, ./components, ./components/<name>, ./corpus, ./corpus/<name>, ./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"