Initial Commit
This commit is contained in:
181
openspec/specs/react-audio-fingerprint/spec.md
Normal file
181
openspec/specs/react-audio-fingerprint/spec.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user