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

182 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->