Initial Commit
This commit is contained in:
21
packages/ui/react/README.md
Normal file
21
packages/ui/react/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# `react`
|
||||
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `react`
|
||||
|
||||
9 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
|
||||
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
|
||||
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
|
||||
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
|
||||
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
|
||||
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
|
||||
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
|
||||
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
|
||||
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/ui/react/_intro.md
Normal file
1
packages/ui/react/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
91
packages/ui/react/react-audio-fingerprint/README.md
Normal file
91
packages/ui/react/react-audio-fingerprint/README.md
Normal file
@@ -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.
|
||||
|
||||
159
packages/ui/react/react-audio-fingerprint/package.json
Normal file
159
packages/ui/react/react-audio-fingerprint/package.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"name": "@suaveplan/react-audio-fingerprint",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"browser": "./dist/index.js",
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./fingerprint": {
|
||||
"import": {
|
||||
"types": "./dist/fingerprint/index.d.ts",
|
||||
"default": "./dist/fingerprint/index.js"
|
||||
}
|
||||
},
|
||||
"./fingerprint/peak-detector": {
|
||||
"import": {
|
||||
"types": "./dist/fingerprint/peak-detector/index.d.ts",
|
||||
"default": "./dist/fingerprint/peak-detector/index.js"
|
||||
}
|
||||
},
|
||||
"./fingerprint/constellation": {
|
||||
"import": {
|
||||
"types": "./dist/fingerprint/constellation/index.d.ts",
|
||||
"default": "./dist/fingerprint/constellation/index.js"
|
||||
}
|
||||
},
|
||||
"./fingerprint/audio-fingerprinter": {
|
||||
"import": {
|
||||
"types": "./dist/fingerprint/audio-fingerprinter/index.d.ts",
|
||||
"default": "./dist/fingerprint/audio-fingerprinter/index.js"
|
||||
}
|
||||
},
|
||||
"./fingerprint/fingerprint-matcher": {
|
||||
"import": {
|
||||
"types": "./dist/fingerprint/fingerprint-matcher/index.d.ts",
|
||||
"default": "./dist/fingerprint/fingerprint-matcher/index.js"
|
||||
}
|
||||
},
|
||||
"./corpus": {
|
||||
"import": {
|
||||
"types": "./dist/corpus/index.d.ts",
|
||||
"default": "./dist/corpus/index.js"
|
||||
}
|
||||
},
|
||||
"./corpus/memory-corpus": {
|
||||
"import": {
|
||||
"types": "./dist/corpus/memory-corpus/index.d.ts",
|
||||
"default": "./dist/corpus/memory-corpus/index.js"
|
||||
}
|
||||
},
|
||||
"./backends": {
|
||||
"import": {
|
||||
"types": "./dist/backends/index.d.ts",
|
||||
"default": "./dist/backends/index.js"
|
||||
}
|
||||
},
|
||||
"./backends/local-backend": {
|
||||
"import": {
|
||||
"types": "./dist/backends/local-backend/index.d.ts",
|
||||
"default": "./dist/backends/local-backend/index.js"
|
||||
}
|
||||
},
|
||||
"./hooks": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/index.d.ts",
|
||||
"default": "./dist/hooks/index.js"
|
||||
}
|
||||
},
|
||||
"./hooks/use-audio-fingerprint": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/use-audio-fingerprint/index.d.ts",
|
||||
"default": "./dist/hooks/use-audio-fingerprint/index.js"
|
||||
}
|
||||
},
|
||||
"./components": {
|
||||
"import": {
|
||||
"types": "./dist/components/index.d.ts",
|
||||
"default": "./dist/components/index.js"
|
||||
}
|
||||
},
|
||||
"./components/fingerprint-match-display": {
|
||||
"import": {
|
||||
"types": "./dist/components/fingerprint-match-display/index.d.ts",
|
||||
"default": "./dist/components/fingerprint-match-display/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-analysis-spectrum": "workspace:^",
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/audio-engine-browser": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LocalBackend } from "./local-backend/local-backend.js";
|
||||
export type { FingerprintBackend } from "./local-backend/local-backend.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LocalBackend } from "./local-backend.js";
|
||||
export type { FingerprintBackend } from "./local-backend.types.js";
|
||||
@@ -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,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc, twoTone } from "../../../tests/fixtures.js";
|
||||
import { MemoryCorpus } from "../../corpus/memory-corpus/memory-corpus.js";
|
||||
import { LocalBackend } from "./local-backend.js";
|
||||
|
||||
describe("LocalBackend", () => {
|
||||
it("fingerprints a document", { timeout: 30000 }, async () => {
|
||||
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
|
||||
const fp = await new LocalBackend().fingerprint(doc, {});
|
||||
expect(fp.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("matches against a populated corpus", { timeout: 30000 }, async () => {
|
||||
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
|
||||
const backend = new LocalBackend();
|
||||
const fp = await backend.fingerprint(doc);
|
||||
const corpus = new MemoryCorpus();
|
||||
await corpus.add("ref", fp);
|
||||
const ranked = await backend.match(fp, corpus);
|
||||
expect(ranked[0]?.id).toBe("ref");
|
||||
expect(ranked[0]?.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns an empty match list against an empty corpus", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
|
||||
const backend = new LocalBackend();
|
||||
const fp = await backend.fingerprint(doc);
|
||||
const corpus = new MemoryCorpus();
|
||||
const ranked = await backend.match(fp, corpus);
|
||||
expect(ranked).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import { AudioFingerprinter } from "../../fingerprint/audio-fingerprinter/audio-fingerprinter.js";
|
||||
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
|
||||
import { FingerprintMatcher } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.js";
|
||||
import type {
|
||||
CorpusLike,
|
||||
MatchResult,
|
||||
} from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
import type { FingerprintBackend } from "./local-backend.types.js";
|
||||
|
||||
/**
|
||||
* Default `FingerprintBackend` — runs the constellation fingerprinter and
|
||||
* histogram matcher in the current process. This is the only backend
|
||||
* shipped with the package root; the optional `PythonBackend` lives in
|
||||
* `./backends/python-backend` and is excluded from the root tree-shake.
|
||||
*/
|
||||
export class LocalBackend implements FingerprintBackend {
|
||||
private readonly fingerprinter = new AudioFingerprinter();
|
||||
private readonly matcher = new FingerprintMatcher();
|
||||
|
||||
public async fingerprint(
|
||||
doc: AudioDocument,
|
||||
opts?: {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: { fraction: number }) => void;
|
||||
}
|
||||
): Promise<Fingerprint> {
|
||||
const result = await this.fingerprinter.fingerprint(doc, {
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.onProgress ? { onProgress: opts.onProgress } : {}),
|
||||
});
|
||||
return result.fingerprint;
|
||||
}
|
||||
|
||||
public async match(
|
||||
query: Fingerprint,
|
||||
corpus: CorpusLike
|
||||
): Promise<MatchResult[]> {
|
||||
return this.matcher.match(query, corpus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
|
||||
import type {
|
||||
CorpusLike,
|
||||
MatchResult,
|
||||
} from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
|
||||
/**
|
||||
* Backend abstraction: hooks accept an injected backend so consumers
|
||||
* can swap the local algorithm for a server-side implementation without
|
||||
* touching the React surface.
|
||||
*/
|
||||
export interface FingerprintBackend {
|
||||
fingerprint(
|
||||
doc: AudioDocument,
|
||||
opts?: {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: { fraction: number }) => void;
|
||||
}
|
||||
): Promise<Fingerprint>;
|
||||
match(query: Fingerprint, corpus: CorpusLike): Promise<MatchResult[]>;
|
||||
}
|
||||
@@ -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,40 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { MatchResult } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
import { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
|
||||
|
||||
const SAMPLE: ReadonlyArray<MatchResult> = [
|
||||
{ id: "song-1", score: 240, offsetSec: 1.23, matchedHashCount: 240 },
|
||||
{ id: "song-2", score: 110, offsetSec: 3.5, matchedHashCount: 110 },
|
||||
{ id: "song-3", score: 40, offsetSec: 0.5, matchedHashCount: 40 },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof FingerprintMatchDisplay> = {
|
||||
title: "Fingerprint/FingerprintMatchDisplay",
|
||||
component: FingerprintMatchDisplay,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FingerprintMatchDisplay>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { results: SAMPLE },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { results: [] },
|
||||
};
|
||||
|
||||
export const Selectable: Story = {
|
||||
args: {
|
||||
results: SAMPLE,
|
||||
onSelect: (r) => console.log("selected", r),
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomLabel: Story = {
|
||||
args: {
|
||||
results: SAMPLE,
|
||||
label: "Top library matches",
|
||||
renderTitle: (r) => `track [${r.id}]`,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { MatchResult } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
import { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
|
||||
|
||||
const sample: ReadonlyArray<MatchResult> = [
|
||||
{ id: "alpha", score: 100, offsetSec: 1.23, matchedHashCount: 100 },
|
||||
{ id: "beta", score: 60, offsetSec: 2.5, matchedHashCount: 60 },
|
||||
{ id: "gamma", score: 20, offsetSec: 0.01, matchedHashCount: 20 },
|
||||
];
|
||||
|
||||
describe("FingerprintMatchDisplay", () => {
|
||||
it("renders the empty-state message", { timeout: 5000 }, () => {
|
||||
render(<FingerprintMatchDisplay results={[]} />);
|
||||
expect(screen.getByRole("status").textContent).toContain("No matches");
|
||||
});
|
||||
|
||||
it("renders one row per result with progressbar semantics", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
render(<FingerprintMatchDisplay results={sample} />);
|
||||
expect(screen.getByRole("list").children.length).toBe(3);
|
||||
const bars = screen.getAllByRole("progressbar");
|
||||
expect(bars.length).toBe(3);
|
||||
expect(bars[0]!.getAttribute("aria-valuenow")).toBe("100");
|
||||
});
|
||||
|
||||
it("uses renderTitle when provided", { timeout: 5000 }, () => {
|
||||
render(
|
||||
<FingerprintMatchDisplay
|
||||
results={sample}
|
||||
renderTitle={(r) => `track-${r.id}`}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("track-alpha")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires onSelect on click", { timeout: 5000 }, async () => {
|
||||
let selected: MatchResult | undefined;
|
||||
render(
|
||||
<FingerprintMatchDisplay
|
||||
results={sample}
|
||||
onSelect={(r) => {
|
||||
selected = r;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]!);
|
||||
expect(selected?.id).toBe("alpha");
|
||||
});
|
||||
|
||||
it("fires onSelect via keyboard (Enter)", { timeout: 5000 }, async () => {
|
||||
let count = 0;
|
||||
render(
|
||||
<FingerprintMatchDisplay
|
||||
results={sample}
|
||||
onSelect={() => {
|
||||
count++;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
buttons[1]!.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("applies a custom label", { timeout: 5000 }, () => {
|
||||
render(<FingerprintMatchDisplay results={sample} label="Top hits" />);
|
||||
expect(screen.getByLabelText("Top hits")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("labels the empty state with the supplied label", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
render(<FingerprintMatchDisplay results={[]} label="Top hits" />);
|
||||
expect(screen.getByLabelText("Top hits")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses a width of 100% for a top score of 0", { timeout: 5000 }, () => {
|
||||
const zero: MatchResult[] = [
|
||||
{ id: "z", score: 0, offsetSec: 0, matchedHashCount: 0 },
|
||||
];
|
||||
render(<FingerprintMatchDisplay results={zero} />);
|
||||
const bar = screen.getByRole("progressbar");
|
||||
expect(bar.getAttribute("style")).toContain("width: 0%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { FingerprintMatchDisplayProps } from "./fingerprint-match-display.types.js";
|
||||
|
||||
/**
|
||||
* Ranked match results with accessible confidence bars.
|
||||
*
|
||||
* - Renders a `<ul role="list">` of results, sorted as provided.
|
||||
* - Each row shows the title, confidence bar (width ∝ score), and the
|
||||
* absolute offset in seconds.
|
||||
* - Empty input renders a `<p role="status">` message instead of a list
|
||||
* so screen-reader users learn the search produced nothing.
|
||||
* - Optional `onSelect` lets parents react to row activation; rows are
|
||||
* keyboard-accessible when `onSelect` is provided (button-shaped).
|
||||
*/
|
||||
export function FingerprintMatchDisplay(
|
||||
props: FingerprintMatchDisplayProps
|
||||
): ReactElement {
|
||||
const label = props.label ?? "Match results";
|
||||
|
||||
if (props.results.length === 0) {
|
||||
return (
|
||||
<p role="status" aria-label={label}>
|
||||
No matches found.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const top = props.results[0]!.score || 1;
|
||||
|
||||
return (
|
||||
<ul aria-label={label}>
|
||||
{props.results.map((result) => {
|
||||
const title = props.renderTitle
|
||||
? props.renderTitle(result)
|
||||
: result.id;
|
||||
const widthPct = Math.min(
|
||||
100,
|
||||
Math.round((result.score / top) * 100)
|
||||
);
|
||||
if (props.onSelect) {
|
||||
return (
|
||||
<li key={result.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelect?.(result)}
|
||||
aria-label={`${title} score ${result.score} offset ${result.offsetSec.toFixed(2)} seconds`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={top}
|
||||
aria-valuenow={result.score}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
<span>{result.offsetSec.toFixed(2)}s</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={result.id}>
|
||||
<span>{title}</span>
|
||||
<span
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={top}
|
||||
aria-valuenow={result.score}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
<span>{result.offsetSec.toFixed(2)}s</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MatchResult } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
|
||||
export interface FingerprintMatchDisplayProps {
|
||||
/** Ranked results from `FingerprintMatcher.match`. */
|
||||
readonly results: ReadonlyArray<MatchResult>;
|
||||
/** Optional accessible label. Defaults to `"Match results"`. */
|
||||
readonly label?: string;
|
||||
/** Called when the user activates a row. */
|
||||
readonly onSelect?: (result: MatchResult) => void;
|
||||
/** Optional render override for each row's title. */
|
||||
readonly renderTitle?: (result: MatchResult) => string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
|
||||
export type { FingerprintMatchDisplayProps } from "./fingerprint-match-display.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FingerprintMatchDisplay } from "./fingerprint-match-display/fingerprint-match-display.js";
|
||||
export type { FingerprintMatchDisplayProps } from "./fingerprint-match-display/fingerprint-match-display.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MemoryCorpus } from "./memory-corpus/memory-corpus.js";
|
||||
export type { Corpus } from "./memory-corpus/memory-corpus.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MemoryCorpus } from "./memory-corpus.js";
|
||||
export type { Corpus } from "./memory-corpus.types.js";
|
||||
@@ -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,94 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { MemoryCorpus } from "./memory-corpus.js";
|
||||
|
||||
describe("MemoryCorpus", () => {
|
||||
it("stores and retrieves a fingerprint", { timeout: 5000 }, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
await corpus.add("a", [{ hash: 1, timeOffsetSec: 0 }]);
|
||||
const entry = await corpus.get("a");
|
||||
expect(entry?.id).toBe("a");
|
||||
expect(entry?.fingerprint.length).toBe(1);
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown id", { timeout: 5000 }, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
expect(await corpus.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tracks size across add/remove", { timeout: 5000 }, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
expect(corpus.size()).toBe(0);
|
||||
await corpus.add("a", []);
|
||||
await corpus.add("b", []);
|
||||
expect(corpus.size()).toBe(2);
|
||||
await corpus.remove("a");
|
||||
expect(corpus.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("iterates in insertion order", { timeout: 5000 }, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
await corpus.add("a", [{ hash: 1, timeOffsetSec: 0 }]);
|
||||
await corpus.add("b", [{ hash: 2, timeOffsetSec: 0 }]);
|
||||
await corpus.add("c", [{ hash: 3, timeOffsetSec: 0 }]);
|
||||
const ids = [...corpus.values()].map((e) => e.id);
|
||||
expect(ids).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("fires subscribers on add and remove", { timeout: 5000 }, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
let count = 0;
|
||||
const unsub = corpus.subscribe(() => {
|
||||
count++;
|
||||
});
|
||||
await corpus.add("a", []);
|
||||
await corpus.remove("a");
|
||||
expect(count).toBe(2);
|
||||
unsub();
|
||||
await corpus.add("b", []);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("does not fire subscribers on a no-op remove", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
let count = 0;
|
||||
corpus.subscribe(() => {
|
||||
count++;
|
||||
});
|
||||
await corpus.remove("missing");
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("throws CORPUS_FAILED on an empty id", { timeout: 5000 }, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
try {
|
||||
await corpus.add("", []);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws CORPUS_FAILED on a non-string id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const corpus = new MemoryCorpus();
|
||||
try {
|
||||
await corpus.add(undefined as unknown as string, []);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
|
||||
import type { FingerprintCorpusEntry } from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
import type { Corpus } from "./memory-corpus.types.js";
|
||||
|
||||
/**
|
||||
* `Map`-backed corpus implementation. Mutations are synchronous; the
|
||||
* `Promise`-returning surface exists so the same API works under
|
||||
* `IndexedDbCorpus` (follow-up) without branching at call sites.
|
||||
*
|
||||
* Every mutation notifies subscribers — used by `useFingerprintMatcher`
|
||||
* to re-run matches when the corpus changes.
|
||||
*/
|
||||
export class MemoryCorpus implements Corpus {
|
||||
private readonly store = new Map<string, Fingerprint>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
public async add(id: string, fingerprint: Fingerprint): Promise<void> {
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED,
|
||||
"react-audio-fingerprint.corpus.invalid-id",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
this.store.set(id, fingerprint);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<void> {
|
||||
if (this.store.delete(id)) this.notify();
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<FingerprintCorpusEntry | undefined> {
|
||||
const fingerprint = this.store.get(id);
|
||||
if (!fingerprint) return undefined;
|
||||
return { id, fingerprint };
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
public *values(): IterableIterator<FingerprintCorpusEntry> {
|
||||
for (const [id, fingerprint] of this.store) yield { id, fingerprint };
|
||||
}
|
||||
|
||||
public subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
CorpusLike,
|
||||
FingerprintCorpusEntry,
|
||||
} from "../../fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
|
||||
/**
|
||||
* Public corpus contract. `MemoryCorpus` is one concrete implementation;
|
||||
* `IndexedDbCorpus` (follow-up) is another. The `'changed'` event fires
|
||||
* after every mutation so React hooks can re-subscribe via
|
||||
* `useSyncExternalStore`.
|
||||
*/
|
||||
export interface Corpus extends CorpusLike {
|
||||
add(
|
||||
id: string,
|
||||
fingerprint: FingerprintCorpusEntry["fingerprint"]
|
||||
): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
get(id: string): Promise<FingerprintCorpusEntry | undefined>;
|
||||
size(): number;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
@@ -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,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
|
||||
ReactAudioFingerprintError,
|
||||
registerReactAudioFingerprintErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("REACT_AUDIO_FINGERPRINT error codes", () => {
|
||||
it("every registered code has metadata", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(REACT_AUDIO_FINGERPRINT_CODES)) {
|
||||
expect(REACT_AUDIO_FINGERPRINT_ERROR_METADATA[code]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("every metadata key matches a registered code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes = new Set<string>(
|
||||
Object.values(REACT_AUDIO_FINGERPRINT_CODES)
|
||||
);
|
||||
for (const key of Object.keys(REACT_AUDIO_FINGERPRINT_ERROR_METADATA)) {
|
||||
expect(codes.has(key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code is reachable from a real call site", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// The package's own tests instantiate every code via the
|
||||
// dedicated throw paths; this test enforces the catalogue
|
||||
// invariant by constructing each one explicitly.
|
||||
for (const code of Object.values(REACT_AUDIO_FINGERPRINT_CODES)) {
|
||||
const err = new ReactAudioFingerprintError(
|
||||
code,
|
||||
`react-audio-fingerprint.test.${code}`
|
||||
);
|
||||
expect(err.code).toBe(code);
|
||||
expect(err).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the category tag", { timeout: 5000 }, () => {
|
||||
const err = new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
|
||||
"x"
|
||||
);
|
||||
expect(err.category).toBe("react-audio-fingerprint");
|
||||
});
|
||||
|
||||
it("registerReactAudioFingerprintErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => {
|
||||
registerReactAudioFingerprintErrorCodes();
|
||||
registerReactAudioFingerprintErrorCodes();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes published by `@suaveplan/react-audio-fingerprint`.
|
||||
*
|
||||
* Every code is namespaced under `REACT_AUDIO_FINGERPRINT.` by
|
||||
* {@link defineCodes}, producing dotted strings such as
|
||||
* `REACT_AUDIO_FINGERPRINT.DECODE_FAILED`. The six codes cover the full
|
||||
* failure surface of the fingerprint package: source decoding (via
|
||||
* `audio-engine-browser`), the constellation algorithm itself, corpus
|
||||
* lookup/match, in-flight cancellation, and Zod option validation.
|
||||
*/
|
||||
export const REACT_AUDIO_FINGERPRINT_CODES = defineCodes(
|
||||
"REACT_AUDIO_FINGERPRINT",
|
||||
[
|
||||
"DECODE_FAILED",
|
||||
"FINGERPRINT_FAILED",
|
||||
"MATCH_FAILED",
|
||||
"CORPUS_FAILED",
|
||||
"ABORTED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const
|
||||
);
|
||||
|
||||
export type ReactAudioFingerprintErrorCode =
|
||||
(typeof REACT_AUDIO_FINGERPRINT_CODES)[keyof typeof REACT_AUDIO_FINGERPRINT_CODES];
|
||||
|
||||
export interface ReactAudioFingerprintErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen registry of metadata keyed by code. Every code in
|
||||
* {@link REACT_AUDIO_FINGERPRINT_CODES} has exactly one entry; the test
|
||||
* suite cross-checks the two sets.
|
||||
*/
|
||||
export const REACT_AUDIO_FINGERPRINT_ERROR_METADATA: Readonly<
|
||||
Record<ReactAudioFingerprintErrorCode, ReactAudioFingerprintErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED]: {
|
||||
description:
|
||||
"the supplied audio source could not be decoded to an AudioDocument via @suaveplan/audio-engine-browser.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED]: {
|
||||
description:
|
||||
"the constellation fingerprinter raised while computing the spectrogram or detecting peaks.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_FINGERPRINT_CODES.MATCH_FAILED]: {
|
||||
description:
|
||||
"the matcher could not score the query against the supplied corpus (e.g. corpus iteration threw).",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_FINGERPRINT_CODES.CORPUS_FAILED]: {
|
||||
description:
|
||||
"a corpus mutation (add/remove/get) failed; the underlying store is in an inconsistent state.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_FINGERPRINT_CODES.ABORTED]: {
|
||||
description:
|
||||
"the caller's AbortSignal fired before the fingerprint or match completed.",
|
||||
severity: "warning",
|
||||
},
|
||||
[REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS]: {
|
||||
description:
|
||||
"fingerprint/match options failed Zod boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Base error class for every error thrown by
|
||||
* `@suaveplan/react-audio-fingerprint`. Every instance carries a code from
|
||||
* {@link REACT_AUDIO_FINGERPRINT_CODES}, a `messageKey` suitable for i18n
|
||||
* resolution, and optional `context` / `cause`.
|
||||
*
|
||||
* Extends {@link SuaveplanError} so consumers can discriminate via
|
||||
* `instanceof` or the stable `error.code` property.
|
||||
*/
|
||||
export class ReactAudioFingerprintError extends SuaveplanError<ReactAudioFingerprintErrorCode> {
|
||||
public readonly category = "react-audio-fingerprint" as const;
|
||||
|
||||
public constructor(
|
||||
code: ReactAudioFingerprintErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = `ReactAudioFingerprintError(${REACT_AUDIO_FINGERPRINT_ERROR_METADATA[code].severity})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. The metadata is frozen at module load
|
||||
* and re-exported directly; this function exists for parity with the
|
||||
* `registerXxxErrorCodes()` convention adopted by sibling Suaveplan
|
||||
* packages and is safe to call any number of times.
|
||||
*/
|
||||
export function registerReactAudioFingerprintErrorCodes(): void {
|
||||
/* metadata is module-static; no-op. */
|
||||
}
|
||||
|
||||
registerReactAudioFingerprintErrorCodes();
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ReactAudioFingerprintErrorCode,
|
||||
ReactAudioFingerprintErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
|
||||
ReactAudioFingerprintError,
|
||||
registerReactAudioFingerprintErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
@@ -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,223 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc, sineWave, twoTone } from "../../../tests/fixtures.js";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { AudioFingerprinter } from "./audio-fingerprinter.js";
|
||||
|
||||
describe("AudioFingerprinter.fingerprint", () => {
|
||||
it("produces a non-empty fingerprint for a two-tone clip", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.5, 440, 1320, 48_000), 48_000);
|
||||
const result = await new AudioFingerprinter().fingerprint(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
});
|
||||
expect(result.fingerprint.length).toBeGreaterThan(0);
|
||||
expect(result.sampleRate).toBe(48_000);
|
||||
expect(result.hopSize).toBe(512);
|
||||
expect(result.frameSize).toBe(2048);
|
||||
});
|
||||
|
||||
it("applies defaults when no options are supplied", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
|
||||
const result = await new AudioFingerprinter().fingerprint(doc);
|
||||
expect(result.frameSize).toBe(4096);
|
||||
expect(result.hopSize).toBe(1024);
|
||||
expect(result.sampleRate).toBe(48_000);
|
||||
});
|
||||
|
||||
it("is deterministic on identical input", { timeout: 30000 }, async () => {
|
||||
const samples = sineWave(0.3, 440, 48_000);
|
||||
const docA = makeDoc(samples, 48_000);
|
||||
const docB = makeDoc(samples, 48_000);
|
||||
const fp = new AudioFingerprinter();
|
||||
const a = await fp.fingerprint(docA, { frameSize: 2048, hopSize: 512 });
|
||||
const b = await fp.fingerprint(docB, { frameSize: 2048, hopSize: 512 });
|
||||
expect(a.fingerprint).toEqual(b.fingerprint);
|
||||
});
|
||||
|
||||
it("reports monotonically increasing progress ending at 1", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.3, 440, 880, 48_000), 48_000);
|
||||
const seen: number[] = [];
|
||||
await new AudioFingerprinter().fingerprint(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
onProgress: ({ fraction }) => seen.push(fraction),
|
||||
});
|
||||
expect(seen[0]).toBe(0);
|
||||
expect(seen[seen.length - 1]).toBe(1);
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
expect(seen[i]!).toBeGreaterThanOrEqual(seen[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ABORTED when the signal is already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(sineWave(0.1, 440, 48_000), 48_000);
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
try {
|
||||
await new AudioFingerprinter().fingerprint(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on a negative hop size", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
|
||||
try {
|
||||
await new AudioFingerprinter().fingerprint(doc, {
|
||||
frameSize: 4096,
|
||||
hopSize: -1,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps spectrogram-stage failures in FINGERPRINT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// A frame size that survives the local schema (positive int)
|
||||
// but is rejected by the upstream analyzer (non-power-of-two)
|
||||
// produces an opaque AudioAnalysisSpectrumError. The fingerprinter
|
||||
// wraps it in FINGERPRINT_FAILED.
|
||||
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
|
||||
try {
|
||||
await new AudioFingerprinter().fingerprint(doc, {
|
||||
frameSize: 7,
|
||||
hopSize: 1,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps unexpected analyzer throws via FINGERPRINT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
|
||||
const fp = new AudioFingerprinter();
|
||||
// Force the internal analyzer to throw an opaque (non-fingerprint)
|
||||
// error by patching its analyze() method; this exercises the
|
||||
// wrap path that turns external errors into FINGERPRINT_FAILED.
|
||||
(
|
||||
fp as unknown as { spectrogram: { analyze: () => Promise<never> } }
|
||||
).spectrogram.analyze = async () => {
|
||||
throw new Error("synthetic analyzer failure");
|
||||
};
|
||||
try {
|
||||
await fp.fingerprint(doc, { frameSize: 2048, hopSize: 512 });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-throws ABORTED when the signal fires mid-analysis", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(sineWave(0.05, 440, 48_000), 48_000);
|
||||
const ctrl = new AbortController();
|
||||
const fp = new AudioFingerprinter();
|
||||
(
|
||||
fp as unknown as { spectrogram: { analyze: () => Promise<never> } }
|
||||
).spectrogram.analyze = async () => {
|
||||
ctrl.abort();
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
|
||||
"synthetic"
|
||||
);
|
||||
};
|
||||
try {
|
||||
await fp.fingerprint(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks abort after the peak-detector stage", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
|
||||
const ctrl = new AbortController();
|
||||
const fp = new AudioFingerprinter();
|
||||
try {
|
||||
await fp.fingerprint(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
signal: ctrl.signal,
|
||||
onProgress: ({ fraction }) => {
|
||||
if (fraction >= 0.5 && !ctrl.signal.aborted) ctrl.abort();
|
||||
},
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks abort just before constellation pair construction", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
|
||||
const ctrl = new AbortController();
|
||||
const fp = new AudioFingerprinter();
|
||||
try {
|
||||
await fp.fingerprint(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
signal: ctrl.signal,
|
||||
onProgress: ({ fraction }) => {
|
||||
if (fraction >= 0.8 && !ctrl.signal.aborted) ctrl.abort();
|
||||
},
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Spectrogram } from "@suaveplan/audio-analysis-spectrum";
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import z from "zod";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { buildConstellation } from "../constellation/constellation.js";
|
||||
import { detectPeaks } from "../peak-detector/peak-detector.js";
|
||||
import type {
|
||||
FingerprintOptions,
|
||||
FingerprintResult,
|
||||
} from "./audio-fingerprinter.types.js";
|
||||
|
||||
/**
|
||||
* Pre-parse option shape — every field optional; the schema applies the
|
||||
* documented defaults.
|
||||
*/
|
||||
export const FingerprintOptionsSchema = z.object({
|
||||
frameSize: z.number().int().positive().default(4096),
|
||||
hopSize: z.number().int().positive().default(1024),
|
||||
window: z.enum(["hann", "hamming", "blackman"]).default("hann"),
|
||||
noiseFloor: z.number().nonnegative().default(1e-3),
|
||||
peaksPerFrame: z.number().int().positive().default(5),
|
||||
neighbourhood: z.number().int().positive().default(3),
|
||||
fanout: z.number().int().positive().default(5),
|
||||
targetZoneSec: z.number().positive().default(5),
|
||||
});
|
||||
|
||||
export type FingerprintOptionsInput = z.input<typeof FingerprintOptionsSchema>;
|
||||
|
||||
function checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
|
||||
"react-audio-fingerprint.fingerprint.aborted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `AudioFingerprinter` composes the FFT/spectrogram primitives from
|
||||
* `@suaveplan/audio-analysis-spectrum` with this package's peak detector
|
||||
* and constellation pair-hasher. The class deliberately owns NO DSP —
|
||||
* it is a thin co-ordinator that produces a deterministic
|
||||
* {@link FingerprintResult}.
|
||||
*/
|
||||
export class AudioFingerprinter {
|
||||
private readonly spectrogram = new Spectrogram();
|
||||
|
||||
public async fingerprint(
|
||||
doc: AudioDocument,
|
||||
raw?: FingerprintOptionsInput & {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: { fraction: number }) => void;
|
||||
}
|
||||
): Promise<FingerprintResult> {
|
||||
const parsed = FingerprintOptionsSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
|
||||
"react-audio-fingerprint.fingerprint.invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data as Omit<
|
||||
FingerprintOptions,
|
||||
"signal" | "onProgress"
|
||||
>;
|
||||
const signal = raw?.signal;
|
||||
const onProgress = raw?.onProgress;
|
||||
|
||||
checkAborted(signal);
|
||||
raw?.onProgress?.({ fraction: 0 });
|
||||
|
||||
let specResult: Awaited<ReturnType<Spectrogram["analyze"]>>;
|
||||
try {
|
||||
specResult = await this.spectrogram.analyze(doc, {
|
||||
frameSize: opts.frameSize,
|
||||
hopSize: opts.hopSize,
|
||||
window: opts.window,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ReactAudioFingerprintError &&
|
||||
err.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
checkAborted(signal);
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED,
|
||||
"react-audio-fingerprint.fingerprint.spectrogram-failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.({ fraction: 0.5 });
|
||||
checkAborted(signal);
|
||||
|
||||
const peaks = detectPeaks(specResult.spectrogram, {
|
||||
neighbourhood: opts.neighbourhood,
|
||||
noiseFloor: opts.noiseFloor,
|
||||
peaksPerFrame: opts.peaksPerFrame,
|
||||
});
|
||||
onProgress?.({ fraction: 0.8 });
|
||||
checkAborted(signal);
|
||||
|
||||
const targetZoneFrames = Math.max(
|
||||
1,
|
||||
Math.round((opts.targetZoneSec * doc.sampleRate) / opts.hopSize)
|
||||
);
|
||||
const fingerprint = buildConstellation(
|
||||
peaks,
|
||||
opts.hopSize,
|
||||
doc.sampleRate,
|
||||
{
|
||||
fanout: opts.fanout,
|
||||
targetZoneFrames,
|
||||
minDeltaFrames: 1,
|
||||
}
|
||||
);
|
||||
|
||||
onProgress?.({ fraction: 1 });
|
||||
|
||||
return {
|
||||
fingerprint,
|
||||
sampleRate: doc.sampleRate,
|
||||
hopSize: opts.hopSize,
|
||||
frameSize: opts.frameSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import type { Fingerprint } from "../constellation/constellation.types.js";
|
||||
|
||||
export interface FingerprintProgress {
|
||||
/** 0..1 monotonic progress fraction. */
|
||||
readonly fraction: number;
|
||||
}
|
||||
|
||||
export type FingerprintProgressListener = (
|
||||
progress: FingerprintProgress
|
||||
) => void;
|
||||
|
||||
export interface FingerprintOptions {
|
||||
readonly frameSize: number;
|
||||
readonly hopSize: number;
|
||||
readonly window: "hann" | "hamming" | "blackman";
|
||||
readonly noiseFloor: number;
|
||||
readonly peaksPerFrame: number;
|
||||
readonly neighbourhood: number;
|
||||
readonly fanout: number;
|
||||
readonly targetZoneSec: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly onProgress: FingerprintProgressListener | undefined;
|
||||
}
|
||||
|
||||
export interface FingerprintResult {
|
||||
readonly fingerprint: Fingerprint;
|
||||
readonly sampleRate: number;
|
||||
readonly hopSize: number;
|
||||
readonly frameSize: number;
|
||||
}
|
||||
|
||||
export interface AudioFingerprinterLike {
|
||||
fingerprint(
|
||||
doc: AudioDocument,
|
||||
opts?: Partial<FingerprintOptions>
|
||||
): Promise<FingerprintResult>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
AudioFingerprinter,
|
||||
type FingerprintOptionsInput,
|
||||
FingerprintOptionsSchema,
|
||||
} from "./audio-fingerprinter.js";
|
||||
export type {
|
||||
AudioFingerprinterLike,
|
||||
FingerprintOptions,
|
||||
FingerprintProgress,
|
||||
FingerprintProgressListener,
|
||||
FingerprintResult,
|
||||
} from "./audio-fingerprinter.types.js";
|
||||
@@ -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,149 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Peak } from "../peak-detector/peak-detector.types.js";
|
||||
import { buildConstellation, packHash, unpackHash } from "./constellation.js";
|
||||
|
||||
const HOP = 512;
|
||||
const SR = 48_000;
|
||||
|
||||
describe("packHash / unpackHash", () => {
|
||||
it("round-trips arbitrary fields", { timeout: 5000 }, () => {
|
||||
const h = packHash(123, 456, 78);
|
||||
expect(unpackHash(h)).toEqual({ binA: 123, binB: 456, dt: 78 });
|
||||
});
|
||||
|
||||
it("masks fields outside their bit-width", { timeout: 5000 }, () => {
|
||||
const h = packHash(0xffff, 0xffff, 0xffff);
|
||||
expect(unpackHash(h)).toEqual({ binA: 0x0fff, binB: 0x0fff, dt: 0xff });
|
||||
});
|
||||
|
||||
it("returns an unsigned 32-bit integer", { timeout: 5000 }, () => {
|
||||
const h = packHash(0x0fff, 0x0fff, 0xff);
|
||||
expect(h >= 0).toBe(true);
|
||||
expect(h <= 0xffffffff).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConstellation", () => {
|
||||
it("returns empty on empty peaks", { timeout: 5000 }, () => {
|
||||
expect(buildConstellation([], HOP, SR)).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on bad grid", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [{ frame: 0, bin: 1, magnitude: 1 }];
|
||||
try {
|
||||
buildConstellation(peaks, 0, SR);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative sample rate via INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const peaks: Peak[] = [{ frame: 0, bin: 1, magnitude: 1 }];
|
||||
try {
|
||||
buildConstellation(peaks, HOP, -1);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid fanout via Zod schema", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [{ frame: 0, bin: 1, magnitude: 1 }];
|
||||
try {
|
||||
buildConstellation(peaks, HOP, SR, { fanout: -1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits pairs sorted by anchor time", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [
|
||||
{ frame: 10, bin: 1, magnitude: 1 },
|
||||
{ frame: 12, bin: 5, magnitude: 1 },
|
||||
{ frame: 20, bin: 9, magnitude: 1 },
|
||||
];
|
||||
const fp = buildConstellation(peaks, HOP, SR, {
|
||||
fanout: 5,
|
||||
targetZoneFrames: 50,
|
||||
minDeltaFrames: 1,
|
||||
});
|
||||
expect(fp.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < fp.length; i++) {
|
||||
expect(fp[i]!.timeOffsetSec).toBeGreaterThanOrEqual(
|
||||
fp[i - 1]!.timeOffsetSec
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops pairs outside the target zone", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [
|
||||
{ frame: 0, bin: 1, magnitude: 1 },
|
||||
{ frame: 500, bin: 2, magnitude: 1 },
|
||||
];
|
||||
const fp = buildConstellation(peaks, HOP, SR, {
|
||||
fanout: 5,
|
||||
targetZoneFrames: 100,
|
||||
minDeltaFrames: 1,
|
||||
});
|
||||
expect(fp).toEqual([]);
|
||||
});
|
||||
|
||||
it("honours fanout cap", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [
|
||||
{ frame: 0, bin: 1, magnitude: 1 },
|
||||
{ frame: 1, bin: 2, magnitude: 1 },
|
||||
{ frame: 2, bin: 3, magnitude: 1 },
|
||||
{ frame: 3, bin: 4, magnitude: 1 },
|
||||
{ frame: 4, bin: 5, magnitude: 1 },
|
||||
];
|
||||
const fp = buildConstellation(peaks, HOP, SR, {
|
||||
fanout: 2,
|
||||
targetZoneFrames: 50,
|
||||
minDeltaFrames: 1,
|
||||
});
|
||||
// Anchor 0 contributes 2, anchor 1 contributes 2, anchor 2
|
||||
// contributes 2, anchor 3 contributes 1, anchor 4 contributes 0.
|
||||
expect(fp.length).toBe(7);
|
||||
});
|
||||
|
||||
it("honours minDeltaFrames", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [
|
||||
{ frame: 0, bin: 1, magnitude: 1 },
|
||||
{ frame: 0, bin: 2, magnitude: 1 },
|
||||
{ frame: 5, bin: 3, magnitude: 1 },
|
||||
];
|
||||
const fp = buildConstellation(peaks, HOP, SR, {
|
||||
fanout: 5,
|
||||
targetZoneFrames: 50,
|
||||
minDeltaFrames: 1,
|
||||
});
|
||||
// The same-frame pair is dropped; only anchor[0]->peak[2] and
|
||||
// anchor[1]->peak[2] remain.
|
||||
expect(fp.length).toBe(2);
|
||||
});
|
||||
|
||||
it("produces deterministic output", { timeout: 5000 }, () => {
|
||||
const peaks: Peak[] = [
|
||||
{ frame: 0, bin: 1, magnitude: 1 },
|
||||
{ frame: 3, bin: 9, magnitude: 1 },
|
||||
{ frame: 7, bin: 4, magnitude: 1 },
|
||||
];
|
||||
const a = buildConstellation(peaks, HOP, SR);
|
||||
const b = buildConstellation(peaks, HOP, SR);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Peak } from "../peak-detector/peak-detector.types.js";
|
||||
import type {
|
||||
ConstellationOptions,
|
||||
Fingerprint,
|
||||
FingerprintPair,
|
||||
} from "./constellation.types.js";
|
||||
|
||||
/**
|
||||
* Defaults: fanout=5 (each anchor pairs with up to 5 future peaks),
|
||||
* targetZoneFrames=200 (~ a few seconds at 512 hop / 48 kHz), and a
|
||||
* minimum 1-frame separation to avoid self-pairs at the same time slice.
|
||||
*/
|
||||
export const ConstellationOptionsSchema = z.object({
|
||||
fanout: z.number().int().positive().default(5),
|
||||
targetZoneFrames: z.number().int().positive().default(200),
|
||||
minDeltaFrames: z.number().int().nonnegative().default(1),
|
||||
});
|
||||
|
||||
export type ConstellationOptionsInput = z.input<
|
||||
typeof ConstellationOptionsSchema
|
||||
>;
|
||||
|
||||
function parseOptions(raw?: ConstellationOptionsInput): ConstellationOptions {
|
||||
const result = ConstellationOptionsSchema.safeParse(raw ?? {});
|
||||
if (!result.success) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
|
||||
"react-audio-fingerprint.constellation.invalid-options",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a pair `(binA, binB, dt)` into a 32-bit hash.
|
||||
* Layout: `binA[12 bits] | binB[12 bits] | dt[8 bits]`. Bins are masked
|
||||
* to 12 bits (0..4095, covers a 4096-point FFT's 2049 bins). Δt is
|
||||
* clamped to 0..255 frames — pairs outside that range are rejected by
|
||||
* the caller and never reach this function.
|
||||
*/
|
||||
export function packHash(binA: number, binB: number, dt: number): number {
|
||||
const a = binA & 0x0fff;
|
||||
const b = binB & 0x0fff;
|
||||
const d = dt & 0xff;
|
||||
// Result is a 32-bit unsigned integer; explicit `>>> 0` to keep TS
|
||||
// happy about the sign bit when binA dominates the top nibble.
|
||||
return ((a << 20) | (b << 8) | d) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a hash back into its three fields. Inverse of {@link packHash}.
|
||||
*/
|
||||
export function unpackHash(hash: number): {
|
||||
binA: number;
|
||||
binB: number;
|
||||
dt: number;
|
||||
} {
|
||||
return {
|
||||
binA: (hash >>> 20) & 0x0fff,
|
||||
binB: (hash >>> 8) & 0x0fff,
|
||||
dt: hash & 0xff,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a constellation fingerprint from detected peaks. For each anchor
|
||||
* peak the function emits up to `fanout` pair-hashes against subsequent
|
||||
* peaks whose frame difference falls within `[minDeltaFrames,
|
||||
* targetZoneFrames]` AND ≤ 255 (the wire limit imposed by the 8-bit Δt
|
||||
* field in {@link packHash}). Output is sorted by ascending
|
||||
* `timeOffsetSec`. The conversion from frame index to seconds uses
|
||||
* `hopSamples / sampleRate`.
|
||||
*/
|
||||
export function buildConstellation(
|
||||
peaks: ReadonlyArray<Peak>,
|
||||
hopSamples: number,
|
||||
sampleRate: number,
|
||||
raw?: ConstellationOptionsInput
|
||||
): Fingerprint {
|
||||
const opts = parseOptions(raw);
|
||||
if (peaks.length === 0) return [];
|
||||
if (hopSamples <= 0 || sampleRate <= 0) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
|
||||
"react-audio-fingerprint.constellation.bad-grid",
|
||||
{ context: { hopSamples, sampleRate } }
|
||||
);
|
||||
}
|
||||
const secPerFrame = hopSamples / sampleRate;
|
||||
const out: FingerprintPair[] = [];
|
||||
const maxDt = Math.min(opts.targetZoneFrames, 255);
|
||||
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
const anchor = peaks[i]!;
|
||||
let emitted = 0;
|
||||
for (let j = i + 1; j < peaks.length && emitted < opts.fanout; j++) {
|
||||
const target = peaks[j]!;
|
||||
const dt = target.frame - anchor.frame;
|
||||
if (dt < opts.minDeltaFrames) continue;
|
||||
if (dt > maxDt) break;
|
||||
out.push({
|
||||
hash: packHash(anchor.bin, target.bin, dt),
|
||||
timeOffsetSec: anchor.frame * secPerFrame,
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.timeOffsetSec - b.timeOffsetSec);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* A constellation pair hash plus its anchor time offset.
|
||||
*
|
||||
* - `hash` — 32-bit unsigned integer packing `{ binA, binB, dt }`.
|
||||
* - `timeOffsetSec` — anchor-peak time in seconds.
|
||||
*/
|
||||
export interface FingerprintPair {
|
||||
readonly hash: number;
|
||||
readonly timeOffsetSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete fingerprint — a time-sorted list of constellation pairs.
|
||||
*/
|
||||
export type Fingerprint = ReadonlyArray<FingerprintPair>;
|
||||
|
||||
export interface ConstellationOptions {
|
||||
readonly fanout: number;
|
||||
readonly targetZoneFrames: number;
|
||||
readonly minDeltaFrames: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
buildConstellation,
|
||||
type ConstellationOptionsInput,
|
||||
ConstellationOptionsSchema,
|
||||
packHash,
|
||||
unpackHash,
|
||||
} from "./constellation.js";
|
||||
export type {
|
||||
ConstellationOptions,
|
||||
Fingerprint,
|
||||
FingerprintPair,
|
||||
} from "./constellation.types.js";
|
||||
@@ -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,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Fingerprint } from "../constellation/constellation.types.js";
|
||||
import { FingerprintMatcher } from "./fingerprint-matcher.js";
|
||||
|
||||
function fp(pairs: ReadonlyArray<[number, number]>): Fingerprint {
|
||||
return pairs.map(([hash, timeOffsetSec]) => ({ hash, timeOffsetSec }));
|
||||
}
|
||||
|
||||
describe("FingerprintMatcher.matchOne", () => {
|
||||
it("returns zero on empty query", { timeout: 5000 }, () => {
|
||||
const r = new FingerprintMatcher().matchOne(fp([]), fp([[1, 0]]));
|
||||
expect(r).toEqual({ score: 0, offsetSec: 0, matchedHashCount: 0 });
|
||||
});
|
||||
|
||||
it("returns zero on empty candidate", { timeout: 5000 }, () => {
|
||||
const r = new FingerprintMatcher().matchOne(fp([[1, 0]]), fp([]));
|
||||
expect(r).toEqual({ score: 0, offsetSec: 0, matchedHashCount: 0 });
|
||||
});
|
||||
|
||||
it("scores aligned identical fingerprints highly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = fp([
|
||||
[10, 0.1],
|
||||
[20, 0.5],
|
||||
[30, 1.2],
|
||||
]);
|
||||
const r = new FingerprintMatcher().matchOne(a, a);
|
||||
expect(r.score).toBe(3);
|
||||
expect(Math.abs(r.offsetSec)).toBeLessThan(0.001);
|
||||
});
|
||||
|
||||
it("recovers the alignment offset when candidate is the query shifted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const q = fp([
|
||||
[10, 0.1],
|
||||
[20, 0.5],
|
||||
[30, 1.2],
|
||||
]);
|
||||
const shift = 1.5;
|
||||
const c = q.map((p) => ({
|
||||
hash: p.hash,
|
||||
timeOffsetSec: p.timeOffsetSec + shift,
|
||||
}));
|
||||
const r = new FingerprintMatcher().matchOne(q, c);
|
||||
expect(r.score).toBe(3);
|
||||
expect(Math.abs(r.offsetSec - shift)).toBeLessThan(0.011);
|
||||
});
|
||||
|
||||
it("tracks duplicate-hash matches", { timeout: 5000 }, () => {
|
||||
const q = fp([[1, 0]]);
|
||||
const c = fp([
|
||||
[1, 0],
|
||||
[1, 5],
|
||||
]);
|
||||
const r = new FingerprintMatcher().matchOne(q, c);
|
||||
expect(r.matchedHashCount).toBe(2);
|
||||
expect(r.score).toBe(1);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on a non-positive bin width", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const q = fp([[1, 0]]);
|
||||
const c = fp([[1, 0]]);
|
||||
try {
|
||||
new FingerprintMatcher().matchOne(q, c, { binWidthSec: 0 });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FingerprintMatcher.match", () => {
|
||||
it("returns results sorted by descending score", { timeout: 5000 }, () => {
|
||||
const q = fp([
|
||||
[1, 0],
|
||||
[2, 0.5],
|
||||
[3, 1.0],
|
||||
]);
|
||||
const corpus = {
|
||||
values: () => [
|
||||
{
|
||||
id: "weak",
|
||||
fingerprint: fp([[1, 0]]),
|
||||
},
|
||||
{
|
||||
id: "strong",
|
||||
fingerprint: fp([
|
||||
[1, 0],
|
||||
[2, 0.5],
|
||||
[3, 1.0],
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: "medium",
|
||||
fingerprint: fp([
|
||||
[1, 0],
|
||||
[2, 0.5],
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = new FingerprintMatcher().match(q, corpus);
|
||||
expect(r.map((x) => x.id)).toEqual(["strong", "medium", "weak"]);
|
||||
});
|
||||
|
||||
it("skips candidates with no shared hashes", { timeout: 5000 }, () => {
|
||||
const q = fp([[1, 0]]);
|
||||
const corpus = {
|
||||
values: () => [
|
||||
{ id: "miss", fingerprint: fp([[99, 0]]) },
|
||||
{ id: "hit", fingerprint: fp([[1, 0]]) },
|
||||
],
|
||||
};
|
||||
const r = new FingerprintMatcher().match(q, corpus);
|
||||
expect(r.map((x) => x.id)).toEqual(["hit"]);
|
||||
});
|
||||
|
||||
it("wraps a throwing corpus in MATCH_FAILED", { timeout: 5000 }, () => {
|
||||
const q = fp([[1, 0]]);
|
||||
const broken = {
|
||||
values: () => {
|
||||
throw new Error("broken");
|
||||
},
|
||||
};
|
||||
try {
|
||||
new FingerprintMatcher().match(q, broken);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.MATCH_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Fingerprint } from "../constellation/constellation.types.js";
|
||||
import type {
|
||||
CorpusLike,
|
||||
MatchOneResult,
|
||||
MatchResult,
|
||||
} from "./fingerprint-matcher.types.js";
|
||||
|
||||
const BIN_WIDTH_SEC = 0.01;
|
||||
|
||||
interface MatcherOptions {
|
||||
/** Histogram bin width in seconds (default 0.01 s = 10 ms). */
|
||||
readonly binWidthSec?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-offset histogram matcher. For each candidate fingerprint the
|
||||
* matcher builds a histogram of `(candidateOffset − queryOffset)` for
|
||||
* every shared hash, then reports the heaviest bin as the match score
|
||||
* and the bin midpoint as the alignment offset. Returns results sorted
|
||||
* by descending score.
|
||||
*/
|
||||
export class FingerprintMatcher {
|
||||
public match(
|
||||
query: Fingerprint,
|
||||
corpus: CorpusLike,
|
||||
opts?: MatcherOptions
|
||||
): MatchResult[] {
|
||||
let entries: Iterable<{ id: string; fingerprint: Fingerprint }>;
|
||||
try {
|
||||
entries = corpus.values();
|
||||
} catch (err) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.MATCH_FAILED,
|
||||
"react-audio-fingerprint.match.iteration-failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const out: MatchResult[] = [];
|
||||
for (const entry of entries) {
|
||||
const m = this.matchOne(query, entry.fingerprint, opts);
|
||||
if (m.matchedHashCount === 0) continue;
|
||||
out.push({
|
||||
id: entry.id,
|
||||
score: m.score,
|
||||
offsetSec: m.offsetSec,
|
||||
matchedHashCount: m.matchedHashCount,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score `query` against a single `candidate`. Returns the heaviest
|
||||
* histogram bucket as `score`, the bucket midpoint as `offsetSec`,
|
||||
* and the count of hashes shared at all (regardless of bucket).
|
||||
*/
|
||||
public matchOne(
|
||||
query: Fingerprint,
|
||||
candidate: Fingerprint,
|
||||
opts?: MatcherOptions
|
||||
): MatchOneResult {
|
||||
const binWidth = opts?.binWidthSec ?? BIN_WIDTH_SEC;
|
||||
if (binWidth <= 0) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
|
||||
"react-audio-fingerprint.match.bad-bin-width",
|
||||
{ context: { binWidthSec: binWidth } }
|
||||
);
|
||||
}
|
||||
if (query.length === 0 || candidate.length === 0) {
|
||||
return { score: 0, offsetSec: 0, matchedHashCount: 0 };
|
||||
}
|
||||
// Index the candidate's hashes → array of times.
|
||||
const index = new Map<number, number[]>();
|
||||
for (const pair of candidate) {
|
||||
const slot = index.get(pair.hash);
|
||||
if (slot) slot.push(pair.timeOffsetSec);
|
||||
else index.set(pair.hash, [pair.timeOffsetSec]);
|
||||
}
|
||||
const histogram = new Map<number, number>();
|
||||
let matchedHashCount = 0;
|
||||
for (const q of query) {
|
||||
const slots = index.get(q.hash);
|
||||
if (!slots) continue;
|
||||
for (const t of slots) {
|
||||
matchedHashCount++;
|
||||
const delta = t - q.timeOffsetSec;
|
||||
const bin = Math.round(delta / binWidth);
|
||||
histogram.set(bin, (histogram.get(bin) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
let bestBin = 0;
|
||||
let bestCount = 0;
|
||||
for (const [bin, count] of histogram) {
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestBin = bin;
|
||||
}
|
||||
}
|
||||
return {
|
||||
score: bestCount,
|
||||
offsetSec: bestBin * binWidth,
|
||||
matchedHashCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Fingerprint } from "../constellation/constellation.types.js";
|
||||
|
||||
export interface MatchResult {
|
||||
readonly id: string;
|
||||
readonly score: number;
|
||||
readonly offsetSec: number;
|
||||
readonly matchedHashCount: number;
|
||||
}
|
||||
|
||||
export interface MatchOneResult {
|
||||
readonly score: number;
|
||||
readonly offsetSec: number;
|
||||
readonly matchedHashCount: number;
|
||||
}
|
||||
|
||||
export interface FingerprintCorpusEntry {
|
||||
readonly id: string;
|
||||
readonly fingerprint: Fingerprint;
|
||||
}
|
||||
|
||||
export interface CorpusLike {
|
||||
values(): Iterable<FingerprintCorpusEntry>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { FingerprintMatcher } from "./fingerprint-matcher.js";
|
||||
export type {
|
||||
CorpusLike,
|
||||
FingerprintCorpusEntry,
|
||||
MatchOneResult,
|
||||
MatchResult,
|
||||
} from "./fingerprint-matcher.types.js";
|
||||
@@ -0,0 +1,40 @@
|
||||
export {
|
||||
AudioFingerprinter,
|
||||
type FingerprintOptionsInput,
|
||||
FingerprintOptionsSchema,
|
||||
} from "./audio-fingerprinter/audio-fingerprinter.js";
|
||||
export type {
|
||||
AudioFingerprinterLike,
|
||||
FingerprintOptions,
|
||||
FingerprintProgress,
|
||||
FingerprintProgressListener,
|
||||
FingerprintResult,
|
||||
} from "./audio-fingerprinter/audio-fingerprinter.types.js";
|
||||
export {
|
||||
buildConstellation,
|
||||
type ConstellationOptionsInput,
|
||||
ConstellationOptionsSchema,
|
||||
packHash,
|
||||
unpackHash,
|
||||
} from "./constellation/constellation.js";
|
||||
export type {
|
||||
ConstellationOptions,
|
||||
Fingerprint,
|
||||
FingerprintPair,
|
||||
} from "./constellation/constellation.types.js";
|
||||
export { FingerprintMatcher } from "./fingerprint-matcher/fingerprint-matcher.js";
|
||||
export type {
|
||||
CorpusLike,
|
||||
FingerprintCorpusEntry,
|
||||
MatchOneResult,
|
||||
MatchResult,
|
||||
} from "./fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
export {
|
||||
detectPeaks,
|
||||
type PeakDetectorOptionsInput,
|
||||
PeakDetectorOptionsSchema,
|
||||
} from "./peak-detector/peak-detector.js";
|
||||
export type {
|
||||
Peak,
|
||||
PeakDetectorOptions,
|
||||
} from "./peak-detector/peak-detector.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
detectPeaks,
|
||||
type PeakDetectorOptionsInput,
|
||||
PeakDetectorOptionsSchema,
|
||||
} from "./peak-detector.js";
|
||||
export type { Peak, PeakDetectorOptions } from "./peak-detector.types.js";
|
||||
@@ -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,97 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { detectPeaks } from "./peak-detector.js";
|
||||
|
||||
function makeSpectrogram(
|
||||
frames: number,
|
||||
bins: number,
|
||||
hot: ReadonlyArray<[number, number, number]> = []
|
||||
): Float32Array[] {
|
||||
const out: Float32Array[] = [];
|
||||
for (let f = 0; f < frames; f++) out.push(new Float32Array(bins));
|
||||
for (const [f, b, v] of hot) out[f]![b] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("detectPeaks", () => {
|
||||
it("returns an empty array on an empty spectrogram", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(detectPeaks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds an isolated peak above the noise floor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const spec = makeSpectrogram(10, 20, [[5, 10, 1.0]]);
|
||||
const peaks = detectPeaks(spec, { neighbourhood: 2, noiseFloor: 0.5 });
|
||||
expect(peaks).toEqual([{ frame: 5, bin: 10, magnitude: 1.0 }]);
|
||||
});
|
||||
|
||||
it("ignores cells below the noise floor", { timeout: 5000 }, () => {
|
||||
const spec = makeSpectrogram(5, 10, [[2, 5, 0.001]]);
|
||||
const peaks = detectPeaks(spec, { noiseFloor: 0.5 });
|
||||
expect(peaks).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects peaksPerFrame cap (loudest first)", { timeout: 5000 }, () => {
|
||||
// Three candidates in one frame; cap at 2 → keep the two largest.
|
||||
const spec = makeSpectrogram(1, 30, [
|
||||
[0, 1, 0.7],
|
||||
[0, 10, 0.9],
|
||||
[0, 20, 0.8],
|
||||
]);
|
||||
const peaks = detectPeaks(spec, { neighbourhood: 1, peaksPerFrame: 2 });
|
||||
expect(peaks.map((p) => p.bin)).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it("treats a larger neighbour as the winner (no peak emitted at the lesser cell)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const spec = makeSpectrogram(3, 3, [
|
||||
[1, 1, 1.0],
|
||||
[1, 2, 2.0],
|
||||
]);
|
||||
const peaks = detectPeaks(spec, { neighbourhood: 1, noiseFloor: 0.5 });
|
||||
// Only the larger neighbour at bin=2 is a peak.
|
||||
expect(peaks).toEqual([{ frame: 1, bin: 2, magnitude: 2.0 }]);
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_FINGERPRINT.INVALID_OPTIONS on bad input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
detectPeaks([new Float32Array(8)], {
|
||||
neighbourhood: -1,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ReactAudioFingerprintError);
|
||||
expect((e as ReactAudioFingerprintError).code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic across runs", { timeout: 5000 }, () => {
|
||||
const spec = makeSpectrogram(8, 12, [
|
||||
[1, 1, 0.6],
|
||||
[4, 5, 0.9],
|
||||
[6, 9, 0.7],
|
||||
]);
|
||||
const a = detectPeaks(spec, { neighbourhood: 2, noiseFloor: 0.5 });
|
||||
const b = detectPeaks(spec, { neighbourhood: 2, noiseFloor: 0.5 });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("applies defaults when no options are supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const spec = makeSpectrogram(5, 5, [[2, 2, 1.0]]);
|
||||
const peaks = detectPeaks(spec);
|
||||
expect(peaks.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Peak, PeakDetectorOptions } from "./peak-detector.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for peak-detector options. Defaults reflect the classic
|
||||
* Shazam constellation paper: a 3-cell-radius neighbourhood, a −60 dBFS
|
||||
* (=> 1e-3 linear) noise floor, and at most 5 peaks per frame.
|
||||
*/
|
||||
export const PeakDetectorOptionsSchema = z.object({
|
||||
neighbourhood: z.number().int().positive().default(3),
|
||||
noiseFloor: z.number().nonnegative().default(1e-3),
|
||||
peaksPerFrame: z.number().int().positive().default(5),
|
||||
});
|
||||
|
||||
export type PeakDetectorOptionsInput = z.input<
|
||||
typeof PeakDetectorOptionsSchema
|
||||
>;
|
||||
|
||||
function parseOptions(raw?: PeakDetectorOptionsInput): PeakDetectorOptions {
|
||||
const result = PeakDetectorOptionsSchema.safeParse(raw ?? {});
|
||||
if (!result.success) {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.INVALID_OPTIONS,
|
||||
"react-audio-fingerprint.peak-detector.invalid-options",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect 2D local-maxima peaks in a spectrogram. For each frame the
|
||||
* function emits up to `peaksPerFrame` peaks: a cell is a peak iff its
|
||||
* magnitude is ≥ all cells within the `neighbourhood`-sized box that
|
||||
* lies within bounds AND ≥ `noiseFloor`. Output is sorted by ascending
|
||||
* `frame` and then descending `magnitude` within a frame, giving callers
|
||||
* a deterministic ordering for downstream hashing.
|
||||
*/
|
||||
export function detectPeaks(
|
||||
spectrogram: ReadonlyArray<Float32Array>,
|
||||
raw?: PeakDetectorOptionsInput
|
||||
): Peak[] {
|
||||
const opts = parseOptions(raw);
|
||||
const peaks: Peak[] = [];
|
||||
const frames = spectrogram.length;
|
||||
if (frames === 0) return peaks;
|
||||
const bins = spectrogram[0]!.length;
|
||||
const N = opts.neighbourhood;
|
||||
|
||||
for (let f = 0; f < frames; f++) {
|
||||
const frameData = spectrogram[f]!;
|
||||
const frameHits: Peak[] = [];
|
||||
for (let b = 0; b < bins; b++) {
|
||||
const v = frameData[b]!;
|
||||
if (v < opts.noiseFloor) continue;
|
||||
let isPeak = true;
|
||||
const fLo = Math.max(0, f - N);
|
||||
const fHi = Math.min(frames - 1, f + N);
|
||||
const bLo = Math.max(0, b - N);
|
||||
const bHi = Math.min(bins - 1, b + N);
|
||||
for (let nf = fLo; nf <= fHi && isPeak; nf++) {
|
||||
const row = spectrogram[nf]!;
|
||||
for (let nb = bLo; nb <= bHi; nb++) {
|
||||
if (nf === f && nb === b) continue;
|
||||
if (row[nb]! > v) {
|
||||
isPeak = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isPeak) frameHits.push({ frame: f, bin: b, magnitude: v });
|
||||
}
|
||||
frameHits.sort((a, b) => b.magnitude - a.magnitude);
|
||||
for (let i = 0; i < frameHits.length && i < opts.peaksPerFrame; i++) {
|
||||
peaks.push(frameHits[i]!);
|
||||
}
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Detected peak in a single spectrogram frame.
|
||||
*
|
||||
* - `frame` — frame index (0-based, time axis).
|
||||
* - `bin` — frequency-bin index (0-based, frequency axis).
|
||||
* - `magnitude` — linear magnitude reported by the spectrogram for this cell.
|
||||
*/
|
||||
export interface Peak {
|
||||
readonly frame: number;
|
||||
readonly bin: number;
|
||||
readonly magnitude: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tunables for the 2D peak detector. All fields are required after Zod
|
||||
* parse — callers normally use the schema defaults rather than passing
|
||||
* raw structures.
|
||||
*/
|
||||
export interface PeakDetectorOptions {
|
||||
readonly neighbourhood: number;
|
||||
readonly noiseFloor: number;
|
||||
readonly peaksPerFrame: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useAudioFingerprint } from "./use-audio-fingerprint/use-audio-fingerprint.js";
|
||||
export type {
|
||||
FingerprintState,
|
||||
UseAudioFingerprintOptions,
|
||||
UseAudioFingerprintResult,
|
||||
UseAudioFingerprintSource,
|
||||
} from "./use-audio-fingerprint/use-audio-fingerprint.types.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useAudioFingerprint } from "./use-audio-fingerprint.js";
|
||||
export type {
|
||||
FingerprintState,
|
||||
UseAudioFingerprintOptions,
|
||||
UseAudioFingerprintResult,
|
||||
UseAudioFingerprintSource,
|
||||
} from "./use-audio-fingerprint.types.js";
|
||||
@@ -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,212 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { makeDoc, twoTone } from "../../../tests/fixtures.js";
|
||||
import type { FingerprintBackend } from "../../backends/local-backend/local-backend.types.js";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { useAudioFingerprint } from "./use-audio-fingerprint.js";
|
||||
|
||||
function makeBackend(behaviour: {
|
||||
delayMs?: number;
|
||||
fail?: "code" | "raw" | "abort";
|
||||
}): FingerprintBackend {
|
||||
return {
|
||||
async fingerprint(_doc, opts) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (opts?.signal?.aborted) {
|
||||
reject(
|
||||
new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
|
||||
"test.abort"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
opts?.onProgress?.({ fraction: 0.5 });
|
||||
resolve();
|
||||
}, behaviour.delayMs ?? 10);
|
||||
opts?.signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
|
||||
"test.abort"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
if (behaviour.fail === "code") {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED,
|
||||
"test.code"
|
||||
);
|
||||
}
|
||||
if (behaviour.fail === "raw") {
|
||||
throw new Error("plain failure");
|
||||
}
|
||||
opts?.onProgress?.({ fraction: 1 });
|
||||
return [{ hash: 1, timeOffsetSec: 0 }];
|
||||
},
|
||||
async match() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useAudioFingerprint", () => {
|
||||
it("settles in idle when source is undefined", { timeout: 5000 }, () => {
|
||||
const { result } = renderHook(() => useAudioFingerprint(undefined));
|
||||
expect(result.current.state).toBe("idle");
|
||||
expect(result.current.fingerprint).toBeUndefined();
|
||||
expect(result.current.progress).toBe(0);
|
||||
});
|
||||
|
||||
it("advances to done with a fingerprint", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
const backend = makeBackend({});
|
||||
const { result } = renderHook(() =>
|
||||
useAudioFingerprint(doc, { backend })
|
||||
);
|
||||
await waitFor(() => expect(result.current.state).toBe("done"), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(result.current.fingerprint?.length).toBe(1);
|
||||
expect(result.current.progress).toBe(1);
|
||||
});
|
||||
|
||||
it("surfaces ReactAudioFingerprintError on failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
const backend = makeBackend({ fail: "code" });
|
||||
const { result } = renderHook(() =>
|
||||
useAudioFingerprint(doc, { backend })
|
||||
);
|
||||
await waitFor(() => expect(result.current.state).toBe("error"), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(result.current.error?.code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps non-suaveplan errors in FINGERPRINT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
const backend = makeBackend({ fail: "raw" });
|
||||
const { result } = renderHook(() =>
|
||||
useAudioFingerprint(doc, { backend })
|
||||
);
|
||||
await waitFor(() => expect(result.current.state).toBe("error"), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(result.current.error?.code).toBe(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("settles to idle when backend rejects with ABORTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
const backend: FingerprintBackend = {
|
||||
async fingerprint() {
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.ABORTED,
|
||||
"test.abort-direct"
|
||||
);
|
||||
},
|
||||
async match() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAudioFingerprint(doc, { backend })
|
||||
);
|
||||
await waitFor(() => expect(result.current.state).toBe("idle"));
|
||||
expect(result.current.progress).toBe(0);
|
||||
expect(result.current.fingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("settles to idle on unmount-triggered abort", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
const backend = makeBackend({ delayMs: 200 });
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useAudioFingerprint(doc, { backend })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toBe("fingerprinting")
|
||||
);
|
||||
await act(async () => {
|
||||
unmount();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
// No additional asserts — the test passes if no error is thrown
|
||||
// and `alive` correctly suppresses post-unmount state updates.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses progress + resolution after unmount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
// Backend that ignores the signal: it resolves after a delay
|
||||
// and emits a progress event on the way through. We unmount
|
||||
// before the resolution fires, exercising both the
|
||||
// `if (alive)` and `if (!alive) return;` false-paths.
|
||||
const backend: FingerprintBackend = {
|
||||
async fingerprint(_doc, opts) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
opts?.onProgress?.({ fraction: 0.5 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return [{ hash: 7, timeOffsetSec: 0 }];
|
||||
},
|
||||
async match() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { unmount } = renderHook(() =>
|
||||
useAudioFingerprint(doc, { backend })
|
||||
);
|
||||
unmount();
|
||||
// Wait long enough for both the progress callback and the
|
||||
// resolution to fire on the dead closure.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
// Nothing to assert beyond "no crash" — the alive guards
|
||||
// suppress all state updates.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("resets to idle when source becomes null after success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.1, 440, 880, 48_000), 48_000);
|
||||
const backend = makeBackend({});
|
||||
const { result, rerender } = renderHook(
|
||||
({ src }: { src: typeof doc | null }) =>
|
||||
useAudioFingerprint(src, { backend }),
|
||||
{ initialProps: { src: doc } }
|
||||
);
|
||||
await waitFor(() => expect(result.current.state).toBe("done"));
|
||||
rerender({ src: null });
|
||||
await waitFor(() => expect(result.current.state).toBe("idle"));
|
||||
expect(result.current.fingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults to LocalBackend when no backend prop is passed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(twoTone(0.2, 440, 880, 48_000), 48_000);
|
||||
const { result } = renderHook(() => useAudioFingerprint(doc));
|
||||
await waitFor(() => expect(result.current.state).toBe("done"), {
|
||||
timeout: 25000,
|
||||
});
|
||||
expect(result.current.fingerprint).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { LocalBackend } from "../../backends/local-backend/local-backend.js";
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
|
||||
import type {
|
||||
FingerprintState,
|
||||
UseAudioFingerprintOptions,
|
||||
UseAudioFingerprintResult,
|
||||
UseAudioFingerprintSource,
|
||||
} from "./use-audio-fingerprint.types.js";
|
||||
|
||||
/**
|
||||
* Drive a fingerprint computation off a React-managed source.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `source == null` → `state = 'idle'`, `fingerprint = undefined`.
|
||||
* - `source` provided → kicks the backend, advances through
|
||||
* `'fingerprinting'` (progress 0..1) to `'done'` with the result.
|
||||
* - Unmount or source change aborts the in-flight task via the
|
||||
* `AbortController` created internally; the abandoned promise's
|
||||
* state-mutations are suppressed by an `alive` flag.
|
||||
*
|
||||
* SSR-safe: the initial render returns
|
||||
* `{ state: 'idle', fingerprint: undefined, progress: 0, error: undefined }`
|
||||
* without touching the backend.
|
||||
*/
|
||||
export function useAudioFingerprint(
|
||||
source: UseAudioFingerprintSource,
|
||||
options?: UseAudioFingerprintOptions
|
||||
): UseAudioFingerprintResult {
|
||||
const backend = useMemo(
|
||||
() => options?.backend ?? new LocalBackend(),
|
||||
[options?.backend]
|
||||
);
|
||||
const [state, setState] = useState<FingerprintState>("idle");
|
||||
const [fingerprint, setFingerprint] = useState<Fingerprint | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<ReactAudioFingerprintError | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setState("idle");
|
||||
setFingerprint(undefined);
|
||||
setProgress(0);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const controller = new AbortController();
|
||||
setState("fingerprinting");
|
||||
setFingerprint(undefined);
|
||||
setProgress(0);
|
||||
setError(undefined);
|
||||
backend
|
||||
.fingerprint(source, {
|
||||
signal: controller.signal,
|
||||
onProgress: ({ fraction }) => {
|
||||
if (alive) setProgress(fraction);
|
||||
},
|
||||
})
|
||||
.then((fp) => {
|
||||
if (!alive) return;
|
||||
setFingerprint(fp);
|
||||
setProgress(1);
|
||||
setState("done");
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
if (
|
||||
e instanceof ReactAudioFingerprintError &&
|
||||
e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED
|
||||
) {
|
||||
setState("idle");
|
||||
setProgress(0);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
e instanceof ReactAudioFingerprintError
|
||||
? e
|
||||
: new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.FINGERPRINT_FAILED,
|
||||
"react-audio-fingerprint.hook.unexpected",
|
||||
{ cause: e }
|
||||
)
|
||||
);
|
||||
setState("error");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [source, backend]);
|
||||
|
||||
return { state, fingerprint, progress, error };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import type { FingerprintBackend } from "../../backends/local-backend/local-backend.types.js";
|
||||
import type { ReactAudioFingerprintError } from "../../error-codes/error-codes.js";
|
||||
import type { Fingerprint } from "../../fingerprint/constellation/constellation.types.js";
|
||||
|
||||
export type FingerprintState = "idle" | "fingerprinting" | "done" | "error";
|
||||
|
||||
export interface UseAudioFingerprintResult {
|
||||
readonly state: FingerprintState;
|
||||
readonly fingerprint: Fingerprint | undefined;
|
||||
readonly progress: number;
|
||||
readonly error: ReactAudioFingerprintError | undefined;
|
||||
}
|
||||
|
||||
export interface UseAudioFingerprintOptions {
|
||||
readonly backend?: FingerprintBackend;
|
||||
}
|
||||
|
||||
export type UseAudioFingerprintSource = AudioDocument | undefined | null;
|
||||
63
packages/ui/react/react-audio-fingerprint/src/index.ts
Normal file
63
packages/ui/react/react-audio-fingerprint/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export {
|
||||
type FingerprintBackend,
|
||||
LocalBackend,
|
||||
} from "./backends/local-backend/index.js";
|
||||
export { FingerprintMatchDisplay } from "./components/fingerprint-match-display/fingerprint-match-display.js";
|
||||
export type { FingerprintMatchDisplayProps } from "./components/fingerprint-match-display/fingerprint-match-display.types.js";
|
||||
export { MemoryCorpus } from "./corpus/memory-corpus/memory-corpus.js";
|
||||
export type { Corpus } from "./corpus/memory-corpus/memory-corpus.types.js";
|
||||
export {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
|
||||
ReactAudioFingerprintError,
|
||||
type ReactAudioFingerprintErrorCode,
|
||||
type ReactAudioFingerprintErrorMetadata,
|
||||
registerReactAudioFingerprintErrorCodes,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
AudioFingerprinter,
|
||||
type FingerprintOptionsInput,
|
||||
FingerprintOptionsSchema,
|
||||
} from "./fingerprint/audio-fingerprinter/audio-fingerprinter.js";
|
||||
export type {
|
||||
AudioFingerprinterLike,
|
||||
FingerprintOptions,
|
||||
FingerprintProgress,
|
||||
FingerprintProgressListener,
|
||||
FingerprintResult,
|
||||
} from "./fingerprint/audio-fingerprinter/audio-fingerprinter.types.js";
|
||||
export {
|
||||
buildConstellation,
|
||||
type ConstellationOptionsInput,
|
||||
ConstellationOptionsSchema,
|
||||
packHash,
|
||||
unpackHash,
|
||||
} from "./fingerprint/constellation/constellation.js";
|
||||
export type {
|
||||
ConstellationOptions,
|
||||
Fingerprint,
|
||||
FingerprintPair,
|
||||
} from "./fingerprint/constellation/constellation.types.js";
|
||||
export { FingerprintMatcher } from "./fingerprint/fingerprint-matcher/fingerprint-matcher.js";
|
||||
export type {
|
||||
CorpusLike,
|
||||
FingerprintCorpusEntry,
|
||||
MatchOneResult,
|
||||
MatchResult,
|
||||
} from "./fingerprint/fingerprint-matcher/fingerprint-matcher.types.js";
|
||||
export {
|
||||
detectPeaks,
|
||||
type PeakDetectorOptionsInput,
|
||||
PeakDetectorOptionsSchema,
|
||||
} from "./fingerprint/peak-detector/peak-detector.js";
|
||||
export type {
|
||||
Peak,
|
||||
PeakDetectorOptions,
|
||||
} from "./fingerprint/peak-detector/peak-detector.types.js";
|
||||
export { useAudioFingerprint } from "./hooks/use-audio-fingerprint/use-audio-fingerprint.js";
|
||||
export type {
|
||||
FingerprintState,
|
||||
UseAudioFingerprintOptions,
|
||||
UseAudioFingerprintResult,
|
||||
UseAudioFingerprintSource,
|
||||
} from "./hooks/use-audio-fingerprint/use-audio-fingerprint.types.js";
|
||||
58
packages/ui/react/react-audio-fingerprint/src/test-setup.md
Normal file
58
packages/ui/react/react-audio-fingerprint/src/test-setup.md
Normal file
@@ -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`.
|
||||
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "@suaveplan/testing/runner";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
61
packages/ui/react/react-audio-fingerprint/tests/fixtures.ts
Normal file
61
packages/ui/react/react-audio-fingerprint/tests/fixtures.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
|
||||
const PAGE_SIZE = 1024 * 1024;
|
||||
|
||||
/** Build an `AudioDocument` from a flat mono `Float32Array`. */
|
||||
export function makeDoc(
|
||||
samples: Float32Array,
|
||||
sampleRate = 48_000
|
||||
): AudioDocument {
|
||||
const pages: { channelData: Float32Array[]; startSample: number }[] = [];
|
||||
let offset = 0;
|
||||
while (offset < samples.length) {
|
||||
const end = Math.min(offset + PAGE_SIZE, samples.length);
|
||||
pages.push({
|
||||
channelData: [samples.slice(offset, end)],
|
||||
startSample: offset,
|
||||
});
|
||||
offset = end;
|
||||
}
|
||||
if (pages.length === 0) {
|
||||
pages.push({ channelData: [new Float32Array(0)], startSample: 0 });
|
||||
}
|
||||
return new AudioDocument({
|
||||
sampleRate,
|
||||
channels: 1,
|
||||
sourceLength: samples.length,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
/** Generate a sine-wave Float32Array. */
|
||||
export function sineWave(
|
||||
durationSec: number,
|
||||
freqHz: number,
|
||||
sampleRate = 48_000
|
||||
): Float32Array {
|
||||
const n = Math.floor(durationSec * sampleRate);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = Math.sin((2 * Math.PI * freqHz * i) / sampleRate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sum of two sines (lets the peak detector find more than one frequency). */
|
||||
export function twoTone(
|
||||
durationSec: number,
|
||||
f1: number,
|
||||
f2: number,
|
||||
sampleRate = 48_000
|
||||
): Float32Array {
|
||||
const n = Math.floor(durationSec * sampleRate);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i / sampleRate;
|
||||
out[i] =
|
||||
0.5 * Math.sin(2 * Math.PI * f1 * t) +
|
||||
0.5 * Math.sin(2 * Math.PI * f2 * t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
19
packages/ui/react/react-audio-fingerprint/tsconfig.lib.json
Normal file
19
packages/ui/react/react-audio-fingerprint/tsconfig.lib.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../foundation/core/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
73
packages/ui/react/react-audio-fingerprint/vite.config.ts
Normal file
73
packages/ui/react/react-audio-fingerprint/vite.config.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/react-audio-fingerprint",
|
||||
plugins: [
|
||||
react(),
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
"fingerprint/index": "src/fingerprint/index.ts",
|
||||
"fingerprint/peak-detector/index":
|
||||
"src/fingerprint/peak-detector/index.ts",
|
||||
"fingerprint/constellation/index":
|
||||
"src/fingerprint/constellation/index.ts",
|
||||
"fingerprint/audio-fingerprinter/index":
|
||||
"src/fingerprint/audio-fingerprinter/index.ts",
|
||||
"fingerprint/fingerprint-matcher/index":
|
||||
"src/fingerprint/fingerprint-matcher/index.ts",
|
||||
"corpus/index": "src/corpus/index.ts",
|
||||
"corpus/memory-corpus/index":
|
||||
"src/corpus/memory-corpus/index.ts",
|
||||
"backends/index": "src/backends/index.ts",
|
||||
"backends/local-backend/index":
|
||||
"src/backends/local-backend/index.ts",
|
||||
"hooks/index": "src/hooks/index.ts",
|
||||
"hooks/use-audio-fingerprint/index":
|
||||
"src/hooks/use-audio-fingerprint/index.ts",
|
||||
"components/index": "src/components/index.ts",
|
||||
"components/fingerprint-match-display/index":
|
||||
"src/components/fingerprint-match-display/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/audio-analysis-spectrum",
|
||||
"@suaveplan/audio-core",
|
||||
"@suaveplan/audio-core/document/document",
|
||||
"@suaveplan/audio-engine-browser",
|
||||
"@suaveplan/audio-engine-browser/decode",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
54
packages/ui/react/react-audio-fingerprint/vitest.config.ts
Normal file
54
packages/ui/react/react-audio-fingerprint/vitest.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/**
|
||||
* Standalone Vitest config — bypasses `createTestConfig` because we need the
|
||||
* `@vitejs/plugin-react` plugin to transform `.tsx` test + source files
|
||||
* (JSX runtime). Mirrors the canonical genesis shape (forks pool, polyfills
|
||||
* setup, istanbul 100% thresholds) as documented in CLAUDE.md.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "happy-dom",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
setupFiles: [
|
||||
"@suaveplan/testing/polyfills",
|
||||
"./src/test-setup.ts",
|
||||
],
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/**/*.test.tsx",
|
||||
],
|
||||
exclude: ["dist/**", "node_modules/**", "src/__benchmarks__/**"],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/__benchmarks__/**",
|
||||
"src/test-setup.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
136
packages/ui/react/react-audio-player/README.md
Normal file
136
packages/ui/react/react-audio-player/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
|
||||
|
||||
Track-style media-player surface for the Suaveplan audio family.
|
||||
The package ships two kinds of exports:
|
||||
|
||||
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
|
||||
`<LyricsDisplay>` component implemented in this package.
|
||||
2. **Re-export façade** — namespace and flat aliases for the player
|
||||
UI that already lives in
|
||||
[`@suaveplan/react-audio`](../react-audio/README.md):
|
||||
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
|
||||
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
|
||||
`<PluginChainView>`, `<PresetManager>`, plus hooks
|
||||
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
|
||||
…).
|
||||
|
||||
Consumers can either import directly from `@suaveplan/react-audio`
|
||||
(the original home of those components) or from
|
||||
`@suaveplan/react-audio-player`, whichever reads better in their
|
||||
codebase. The named entry points resolve to the same singletons via
|
||||
ESM re-export — no duplication, no extra bundle weight.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
This package targets the *additive* scope of the
|
||||
`enterprise-react-audio-player` change. The pre-existing player
|
||||
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
|
||||
visualisers, transport controls, mixer, preset manager) already live
|
||||
in `@suaveplan/react-audio`; duplicating them here would violate the
|
||||
"do not duplicate enterprise components" guidance. Instead this
|
||||
package adds:
|
||||
|
||||
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
|
||||
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
|
||||
for the most common entry points.
|
||||
- Stable `REACT_AUDIO_PLAYER.*` error codes.
|
||||
|
||||
### Deferred scope
|
||||
|
||||
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
|
||||
`@suaveplan/react-audio-stream` exists . The spec
|
||||
requirement is retained in the canonical capability spec with a
|
||||
`> Deferred (multi-peer)` marker; once the dependency lands, the
|
||||
hook adds in a follow-up change without breaking this package's
|
||||
public surface.
|
||||
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
|
||||
requirement is retained; consumers needing these today can
|
||||
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
|
||||
optional `./screensavers/*` subpaths will be filled by a focused
|
||||
follow-up change.
|
||||
|
||||
The two hooks consumed by visualisers (`useAnalyser`) and queues
|
||||
(`usePlaylist`) **are** shipped — they were added to
|
||||
`@suaveplan/react-audio` alongside this package and are re-exported
|
||||
here for ergonomic reach.
|
||||
|
||||
## Quick start — karaoke
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AudioProvider,
|
||||
AudioPlayer,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
|
||||
function App({ track, lrc }: { track: string; lrc: string }) {
|
||||
return (
|
||||
<AudioProvider options={{ /* … */ }}>
|
||||
<AudioPlayer source={track} />
|
||||
<KaraokeLine lrc={lrc} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
function KaraokeLine({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Quick start — standalone preview (no provider)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function Preview({ lrc }: { lrc: string }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
const [t, setT] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={0.1}
|
||||
value={t}
|
||||
onChange={(e) => setT(Number(e.currentTarget.value))}
|
||||
aria-label="time"
|
||||
/>
|
||||
<LyricsDisplay lyrics={lyrics} currentSec={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
| Subpath | Surface |
|
||||
| --- | --- |
|
||||
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
|
||||
| `./karaoke` | All karaoke exports. |
|
||||
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
|
||||
| `./karaoke/use-lyrics` | `useLyrics` and types. |
|
||||
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
|
||||
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
|
||||
|
||||
See the per-feature `.md` files for full API references.
|
||||
|
||||
100
packages/ui/react/react-audio-player/package.json
Normal file
100
packages/ui/react/react-audio-player/package.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "@suaveplan/react-audio-player",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./karaoke": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/index.d.ts",
|
||||
"default": "./dist/karaoke/index.js"
|
||||
}
|
||||
},
|
||||
"./karaoke/lrc-parser": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/lrc-parser/lrc-parser.d.ts",
|
||||
"default": "./dist/karaoke/lrc-parser/lrc-parser.js"
|
||||
}
|
||||
},
|
||||
"./karaoke/use-lyrics": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/use-lyrics/use-lyrics.d.ts",
|
||||
"default": "./dist/karaoke/use-lyrics/use-lyrics.js"
|
||||
}
|
||||
},
|
||||
"./karaoke/lyrics-display": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/lyrics-display/lyrics-display.d.ts",
|
||||
"default": "./dist/karaoke/lyrics-display/lyrics-display.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/react-audio": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/react-audio-player`.
|
||||
Every error thrown by a public surface in this package carries a
|
||||
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
|
||||
resolution, and an optional `context` / `cause` chain. Codes are
|
||||
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
|
||||
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
|
||||
stable across versions.
|
||||
|
||||
The registry is the single source of truth for the package's error
|
||||
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
|
||||
and confirms every code is reachable from a real call site, so a
|
||||
code that gets added without a thrower (or a thrower whose code
|
||||
gets removed) is a build break.
|
||||
|
||||
## Features
|
||||
|
||||
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
|
||||
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
|
||||
- Frozen metadata map keyed by code; every entry has a `description`
|
||||
and `severity` (`"error"` | `"warning"`).
|
||||
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
|
||||
stable `category = "react-audio-player"` field.
|
||||
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
|
||||
sibling audio packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
if (!provider) {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
|
||||
"react-audio-player.no-provider"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
try {
|
||||
parseLrc(input);
|
||||
} catch (cause) {
|
||||
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
|
||||
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
|
||||
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
|
||||
cause,
|
||||
context: { severity: meta.severity, bytes: input.length },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
|
||||
five public codes (frozen).
|
||||
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
|
||||
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
|
||||
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
|
||||
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is the workspace's standard helper for producing
|
||||
namespaced code maps; it freezes the result and asserts that every
|
||||
key is a valid identifier at module load. Metadata is declared with
|
||||
`Object.freeze` so consumers (and the test suite) cannot mutate it
|
||||
at runtime.
|
||||
|
||||
`SuaveplanError` is the universal base error from
|
||||
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
|
||||
`cause` plus a stable wire-format serialisation. The
|
||||
`category = "react-audio-player"` literal is the discriminator used
|
||||
by the cross-package error router when an error crosses a package
|
||||
boundary.
|
||||
|
||||
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
|
||||
output includes the severity at a glance. This is a string-only
|
||||
augmentation; it does not change the prototype chain so
|
||||
`instanceof ReactAudioPlayerError` still narrows correctly.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
type ReactAudioPlayerErrorCode,
|
||||
registerReactAudioPlayerErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("REACT_AUDIO_PLAYER error codes", () => {
|
||||
it("every code has metadata", { timeout: 5000 }, () => {
|
||||
for (const key of Object.keys(REACT_AUDIO_PLAYER_CODES)) {
|
||||
const code = REACT_AUDIO_PLAYER_CODES[
|
||||
key as keyof typeof REACT_AUDIO_PLAYER_CODES
|
||||
] as ReactAudioPlayerErrorCode;
|
||||
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.description.length).toBeGreaterThan(10);
|
||||
expect(["error", "warning"]).toContain(meta.severity);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code is reachable from a thrown error", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(REACT_AUDIO_PLAYER_CODES)) {
|
||||
const err = new ReactAudioPlayerError(code, `${code}.test`);
|
||||
expect(err.code).toBe(code);
|
||||
expect(err.category).toBe("react-audio-player");
|
||||
expect(err.name.startsWith("ReactAudioPlayerError(")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("error carries cause + context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
|
||||
"lrc.parse.failed",
|
||||
{ cause, context: { bytes: 42 } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ bytes: 42 });
|
||||
});
|
||||
|
||||
it("registerReactAudioPlayerErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerReactAudioPlayerErrorCodes();
|
||||
registerReactAudioPlayerErrorCodes();
|
||||
// Re-import did not throw and metadata is still intact.
|
||||
expect(Object.isFrozen(REACT_AUDIO_PLAYER_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes for `@suaveplan/react-audio-player`. The five
|
||||
* codes cover the karaoke surface and the package's mount-time
|
||||
* invariants. Multi-peer codes are deliberately not declared here —
|
||||
* they will land alongside the multi-peer hook once
|
||||
* `@suaveplan/react-audio-stream` exists.
|
||||
*/
|
||||
export const REACT_AUDIO_PLAYER_CODES = defineCodes("REACT_AUDIO_PLAYER", [
|
||||
"NO_PROVIDER",
|
||||
"PLAYLIST_EMPTY",
|
||||
"PERSISTENCE_FAILED",
|
||||
"LRC_PARSE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type ReactAudioPlayerErrorCode =
|
||||
(typeof REACT_AUDIO_PLAYER_CODES)[keyof typeof REACT_AUDIO_PLAYER_CODES];
|
||||
|
||||
export interface ReactAudioPlayerErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
export const REACT_AUDIO_PLAYER_ERROR_METADATA: Readonly<
|
||||
Record<ReactAudioPlayerErrorCode, ReactAudioPlayerErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[REACT_AUDIO_PLAYER_CODES.NO_PROVIDER]: {
|
||||
description:
|
||||
"a react-audio-player surface was rendered outside its required <AudioProvider> ancestor from @suaveplan/react-audio.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.PLAYLIST_EMPTY]: {
|
||||
description:
|
||||
"a transport-driving operation was attempted while the playlist contained zero items.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.PERSISTENCE_FAILED]: {
|
||||
description:
|
||||
"the persistence store (localStorage or injected) rejected a read or write.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED]: {
|
||||
description:
|
||||
"parseLrc encountered input it could not validate (non-string, oversize, or no candidate timestamps).",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.INVALID_OPTIONS]: {
|
||||
description:
|
||||
"a public hook or component received an options bundle that failed boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Base error thrown by every public surface of
|
||||
* `@suaveplan/react-audio-player`. Carries one of
|
||||
* `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n, and an
|
||||
* optional `context` / `cause` chain.
|
||||
*/
|
||||
export class ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode> {
|
||||
public readonly category = "react-audio-player" as const;
|
||||
|
||||
public constructor(
|
||||
code: ReactAudioPlayerErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = `ReactAudioPlayerError(${REACT_AUDIO_PLAYER_ERROR_METADATA[code].severity})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. Metadata is module-static so the
|
||||
* function is a no-op; it exists for parity with the
|
||||
* `registerXxxErrorCodes` convention used by the rest of the audio
|
||||
* family.
|
||||
*/
|
||||
export function registerReactAudioPlayerErrorCodes(): void {
|
||||
/* metadata is module-static; no-op. */
|
||||
}
|
||||
|
||||
registerReactAudioPlayerErrorCodes();
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ReactAudioPlayerErrorCode,
|
||||
ReactAudioPlayerErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
registerReactAudioPlayerErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
71
packages/ui/react/react-audio-player/src/index.ts
Normal file
71
packages/ui/react/react-audio-player/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* `@suaveplan/react-audio-player` — track-style media-player surface.
|
||||
*
|
||||
* This root barrel ships two kinds of exports:
|
||||
*
|
||||
* 1. **Karaoke** (`parseLrc`, `activeLyricIndex`, `useLyrics`,
|
||||
* `<LyricsDisplay>`, types) — the package's own additive
|
||||
* surface, implemented in `./karaoke`.
|
||||
* 2. **Re-export façade** of the player UI from
|
||||
* `@suaveplan/react-audio` — namespaced under
|
||||
* `ReactAudio` so consumers can write
|
||||
* `import { ReactAudio } from "@suaveplan/react-audio-player"`
|
||||
* and reach the existing `AudioProvider`, `AudioPlayer`,
|
||||
* `BasicPlayer`, `PremiumPlayer`, `TransportControls`,
|
||||
* `Mixer`, `useAnalyser`, `usePlaylist`, `useTransport`,
|
||||
* hooks, stores, and error codes. A flat alias of the most
|
||||
* common entry points is also forwarded for ergonomic reach.
|
||||
*
|
||||
* Multi-peer playback synchronisation (`useMultiPeerSync` / WebRTC
|
||||
* via `@suaveplan/react-audio-stream`) is deferred — the upstream
|
||||
* `react-audio-stream` package does not yet exist on genesis. The
|
||||
* spec delta retains the requirement; archival folds it into the
|
||||
* canonical capability spec with a clear deferred marker.
|
||||
*/
|
||||
|
||||
// Namespace re-export of @suaveplan/react-audio so consumers can
|
||||
// reach the existing player components through this package's
|
||||
// surface. Using namespace form (`export * as`) rather than bare
|
||||
// `export *` keeps the package's own root barrel non-`export *` per
|
||||
// CLAUDE.md rule 10 while still avoiding hand-listing 60+ symbols.
|
||||
export * as ReactAudio from "@suaveplan/react-audio";
|
||||
// Ergonomic flat aliases for the most common entry points — these
|
||||
// resolve through the namespace export but spare the consumer the
|
||||
// `ReactAudio.` prefix. Each alias is an *explicit* named re-export
|
||||
// (rule 10 compliant).
|
||||
export {
|
||||
AudioPlayer,
|
||||
AudioProvider,
|
||||
BasicPlayer,
|
||||
Mixer,
|
||||
PluginChainView,
|
||||
PremiumPlayer,
|
||||
PresetManager,
|
||||
TrackList,
|
||||
TransportControls,
|
||||
useAnalyser,
|
||||
usePlaylist,
|
||||
useTransport,
|
||||
useTransportTime,
|
||||
} from "@suaveplan/react-audio";
|
||||
// Error codes.
|
||||
export {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
type ReactAudioPlayerErrorCode,
|
||||
type ReactAudioPlayerErrorMetadata,
|
||||
registerReactAudioPlayerErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
// Karaoke (this package's own additive surface).
|
||||
export {
|
||||
activeLyricIndex,
|
||||
type Lyric,
|
||||
LyricsDisplay,
|
||||
type LyricsDisplayProps,
|
||||
type ParseLrcOptions,
|
||||
parseLrc,
|
||||
type UseLyricsOptions,
|
||||
type UseLyricsResult,
|
||||
useLyrics,
|
||||
} from "./karaoke/index.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export { activeLyricIndex, parseLrc } from "./lrc-parser/lrc-parser.js";
|
||||
export type { Lyric, ParseLrcOptions } from "./lrc-parser/lrc-parser.types.js";
|
||||
export { LyricsDisplay } from "./lyrics-display/lyrics-display.js";
|
||||
export type { LyricsDisplayProps } from "./lyrics-display/lyrics-display.types.js";
|
||||
export { useLyrics } from "./use-lyrics/use-lyrics.js";
|
||||
export type {
|
||||
UseLyricsOptions,
|
||||
UseLyricsResult,
|
||||
} from "./use-lyrics/use-lyrics.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { activeLyricIndex, parseLrc } from "./lrc-parser.js";
|
||||
export type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
# `karaoke/lrc-parser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
|
||||
karaoke source into a timestamp-sorted array of `Lyric` entries
|
||||
suitable for the package's `<LyricsDisplay>` and any consumer-built
|
||||
karaoke surface. The parser is universal — it has no React, DOM, or
|
||||
audio-context dependency — so it is safe to call in workers, on the
|
||||
server during SSR, or inside other Suaveplan tools that need to
|
||||
preview lyric timing.
|
||||
|
||||
The companion `activeLyricIndex(lyrics, currentSec)` performs a
|
||||
binary search for the active line and is the bridge between the
|
||||
transport's `seconds` and the `<LyricsDisplay>` highlight state.
|
||||
Both functions are pure; the parser throws structured
|
||||
`ReactAudioPlayerError` for the two invalid-input cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
|
||||
`[h:mm:ss(.xxx)]` timestamp forms.
|
||||
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
|
||||
togglable via `expandMultiTimestamp`.
|
||||
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
|
||||
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
|
||||
- Skips empty lines, malformed lines, and lines with no leading
|
||||
timestamp.
|
||||
- Output is sorted ascending by `timestampSec` and frozen.
|
||||
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
|
||||
or input exceeding `maxLength` (default 1 MiB).
|
||||
- `activeLyricIndex` is O(log n) and allocation-free.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
|
||||
const lyrics = parseLrc(`
|
||||
[ar:Test Artist]
|
||||
[00:10.50]First line
|
||||
[00:25.00]Second line
|
||||
`);
|
||||
// → [{ timestampSec: 10.5, text: "First line" },
|
||||
// { timestampSec: 25.0, text: "Second line" }]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
activeLyricIndex,
|
||||
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function LyricLine({ source }: { source: string }) {
|
||||
const lyrics = useMemo(
|
||||
() => parseLrc(source, { expandMultiTimestamp: false }),
|
||||
[source]
|
||||
);
|
||||
const seconds = useTransportTime();
|
||||
const idx = activeLyricIndex(lyrics, seconds);
|
||||
return (
|
||||
<span aria-live="polite">
|
||||
{idx >= 0 ? lyrics[idx].text : "…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
|
||||
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
|
||||
- `Lyric` — `{ timestampSec: number; text: string }`.
|
||||
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
|
||||
`expandMultiTimestamp?: boolean` (default `true`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
|
||||
captures optional hours, minutes, seconds, and an optional
|
||||
fractional component. The fractional value is padded right with
|
||||
zeros to three digits then divided by 1000, so `.5` parses to 0.500
|
||||
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
|
||||
fractional separator; the `:` form would conflict with the
|
||||
`[h:mm:ss]` extended timestamp grammar.
|
||||
|
||||
Multi-timestamp expansion walks the regex's global state, but only
|
||||
honours timestamps that form a contiguous prefix of the line. A
|
||||
bracketed sequence later in the line is treated as text — many
|
||||
karaoke services emit `[00:10]hello [00:11]world` to mean two
|
||||
separate lyric lines and this parser handles both layouts
|
||||
correctly.
|
||||
|
||||
`activeLyricIndex` uses the upper-bound binary search idiom: pick
|
||||
the greatest index whose timestamp is ≤ current. When `current` is
|
||||
between two entries, the earlier entry wins; when `current`
|
||||
precedes the first entry, `-1` is returned (so the consumer can
|
||||
render an empty active line until the first cue).
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { REACT_AUDIO_PLAYER_CODES } from "../../error-codes/error-codes.js";
|
||||
import { activeLyricIndex, parseLrc } from "./lrc-parser.js";
|
||||
|
||||
describe("parseLrc", () => {
|
||||
it("sorts by timestamp and skips invalid lines", { timeout: 5000 }, () => {
|
||||
const out = parseLrc(
|
||||
"[ar:Foo]\n[00:30.00]Line A\nBroken line\n[00:10.50]Line B"
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ timestampSec: 10.5, text: "Line B" },
|
||||
{ timestampSec: 30, text: "Line A" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips metadata tags", { timeout: 5000 }, () => {
|
||||
const out = parseLrc(
|
||||
"[ar:Artist]\n[ti:Title]\n[al:Album]\n[length:03:24]\n[by:User]\n[offset:0]\n[00:01.00]hello"
|
||||
);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("parses fractional seconds in multiple precisions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseLrc(
|
||||
"[00:01.5]a\n[00:02.50]b\n[00:03.500]c\n[00:04.05]d"
|
||||
);
|
||||
expect(out[0]?.timestampSec).toBeCloseTo(1.5, 3);
|
||||
expect(out[1]?.timestampSec).toBeCloseTo(2.5, 3);
|
||||
expect(out[2]?.timestampSec).toBeCloseTo(3.5, 3);
|
||||
expect(out[3]?.timestampSec).toBeCloseTo(4.05, 3);
|
||||
});
|
||||
|
||||
it("parses h:mm:ss form", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[1:02:03.500]hour");
|
||||
expect(out[0]?.timestampSec).toBeCloseTo(3723.5, 3);
|
||||
});
|
||||
|
||||
it("expands multi-timestamp lines", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[00:10.00][00:20.00]hi");
|
||||
expect(out.length).toBe(2);
|
||||
expect(out[0]?.timestampSec).toBe(10);
|
||||
expect(out[1]?.timestampSec).toBe(20);
|
||||
expect(out[0]?.text).toBe("hi");
|
||||
expect(out[1]?.text).toBe("hi");
|
||||
});
|
||||
|
||||
it("expandMultiTimestamp=false keeps only first timestamp", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseLrc("[00:10.00][00:20.00]hi", {
|
||||
expandMultiTimestamp: false,
|
||||
});
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.timestampSec).toBe(10);
|
||||
expect(out[0]?.text).toBe("[00:20.00]hi");
|
||||
});
|
||||
|
||||
it("bracketed text after a non-prefix timestamp is treated as text", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseLrc("[00:10.00]hello[00:20.00]world");
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.text).toBe("hello[00:20.00]world");
|
||||
});
|
||||
|
||||
it("throws on non-string input", { timeout: 5000 }, () => {
|
||||
expect(() => parseLrc(null as unknown as string)).toThrowError(
|
||||
/lrc.parse.non-string/
|
||||
);
|
||||
try {
|
||||
parseLrc(42 as unknown as string);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as { code?: string }).code).toBe(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on oversize input", { timeout: 5000 }, () => {
|
||||
const huge = "a".repeat(20);
|
||||
expect(() => parseLrc(huge, { maxLength: 10 })).toThrowError(
|
||||
/lrc.parse.too-large/
|
||||
);
|
||||
});
|
||||
|
||||
it("returns frozen array", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[00:01]a");
|
||||
expect(Object.isFrozen(out)).toBe(true);
|
||||
});
|
||||
|
||||
it("CRLF line endings supported", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[00:01]a\r\n[00:02]b\r\n");
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles a 10k-line LRC under 50 ms", { timeout: 5000 }, () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const m = String(Math.floor(i / 60)).padStart(2, "0");
|
||||
const s = String(i % 60).padStart(2, "0");
|
||||
lines.push(`[${m}:${s}.00]line ${i}`);
|
||||
}
|
||||
const start = performance.now();
|
||||
const out = parseLrc(lines.join("\n"));
|
||||
const elapsed = performance.now() - start;
|
||||
expect(out.length).toBe(10_000);
|
||||
expect(elapsed).toBeLessThan(150); // generous CI ceiling
|
||||
});
|
||||
|
||||
it("empty source yields empty array", { timeout: 5000 }, () => {
|
||||
expect(parseLrc("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeLyricIndex", () => {
|
||||
const LYRICS = parseLrc("[00:00]a\n[00:10]b\n[00:20]c\n[00:30]d");
|
||||
|
||||
it("returns -1 for empty input", { timeout: 5000 }, () => {
|
||||
expect(activeLyricIndex([], 5)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -1 before first lyric", { timeout: 5000 }, () => {
|
||||
const others = parseLrc("[00:05]a\n[00:10]b");
|
||||
expect(activeLyricIndex(others, 2)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns the greatest index ≤ currentSec", { timeout: 5000 }, () => {
|
||||
expect(activeLyricIndex(LYRICS, 0)).toBe(0);
|
||||
expect(activeLyricIndex(LYRICS, 9.99)).toBe(0);
|
||||
expect(activeLyricIndex(LYRICS, 10)).toBe(1);
|
||||
expect(activeLyricIndex(LYRICS, 25)).toBe(2);
|
||||
expect(activeLyricIndex(LYRICS, 999)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
ReactAudioPlayerError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";
|
||||
|
||||
const DEFAULT_MAX_LENGTH = 1_048_576; // 1 MiB
|
||||
|
||||
// Matches `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, `[h:mm:ss(.xxx)]`.
|
||||
// Groups: 1=hours? 2=minutes 3=seconds 4=fractional?
|
||||
const TIMESTAMP_RE = /\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:\.(\d{1,3}))?\]/g;
|
||||
|
||||
// Metadata tags `[ar:…]` / `[ti:…]` / etc. — any bracketed lowercase
|
||||
// letter prefix followed by a colon then arbitrary content.
|
||||
const META_TAG_RE = /^\[[a-z]{1,8}:[^\]]*\]\s*$/;
|
||||
|
||||
function toSeconds(
|
||||
h: string | undefined,
|
||||
m: string,
|
||||
s: string,
|
||||
frac: string | undefined
|
||||
): number {
|
||||
const hours = h ? Number.parseInt(h, 10) : 0;
|
||||
const minutes = Number.parseInt(m, 10);
|
||||
const seconds = Number.parseInt(s, 10);
|
||||
if (frac === undefined) return hours * 3600 + minutes * 60 + seconds;
|
||||
const padded = `${frac}000`.slice(0, 3);
|
||||
const ms = Number.parseInt(padded, 10);
|
||||
return hours * 3600 + minutes * 60 + seconds + ms / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LRC source into a timestamp-sorted array of `Lyric` entries.
|
||||
*
|
||||
* - Multi-timestamp lines (`[00:10][00:20]hello`) expand to one
|
||||
* entry per timestamp when `expandMultiTimestamp` is true (default).
|
||||
* - Metadata tags (`[ar:…]`, `[ti:…]`, `[al:…]`, `[length:…]`,
|
||||
* `[by:…]`, `[offset:…]`, `[re:…]`, `[ve:…]`) are ignored.
|
||||
* - Lines without a leading timestamp are skipped.
|
||||
* - Negative or non-finite timestamps are dropped.
|
||||
*
|
||||
* Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
|
||||
* or input exceeding `maxLength`.
|
||||
*/
|
||||
export function parseLrc(
|
||||
source: unknown,
|
||||
opts: ParseLrcOptions = {}
|
||||
): readonly Lyric[] {
|
||||
if (typeof source !== "string") {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
|
||||
"lrc.parse.non-string",
|
||||
{ context: { type: typeof source } }
|
||||
);
|
||||
}
|
||||
const maxLength = opts.maxLength ?? DEFAULT_MAX_LENGTH;
|
||||
if (source.length > maxLength) {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
|
||||
"lrc.parse.too-large",
|
||||
{ context: { bytes: source.length, max: maxLength } }
|
||||
);
|
||||
}
|
||||
const expand = opts.expandMultiTimestamp ?? true;
|
||||
const out: Lyric[] = [];
|
||||
const lines = source.split(/\r?\n/);
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (line === "") continue;
|
||||
if (META_TAG_RE.test(line)) continue;
|
||||
|
||||
TIMESTAMP_RE.lastIndex = 0;
|
||||
const timestamps: number[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = TIMESTAMP_RE.exec(line);
|
||||
while (match !== null) {
|
||||
// Only honour timestamps forming a contiguous prefix.
|
||||
if (match.index !== lastIndex) break;
|
||||
timestamps.push(
|
||||
toSeconds(
|
||||
match[1],
|
||||
match[2] as string,
|
||||
match[3] as string,
|
||||
match[4]
|
||||
)
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
if (!expand) break;
|
||||
match = TIMESTAMP_RE.exec(line);
|
||||
}
|
||||
if (timestamps.length === 0) continue;
|
||||
const text = line.slice(lastIndex).trim();
|
||||
for (const ts of timestamps) {
|
||||
out.push({ timestampSec: ts, text });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.timestampSec - b.timestampSec);
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary search for the active lyric index — the greatest
|
||||
* `timestampSec` not exceeding `currentSec`. Returns `-1` if
|
||||
* `lyrics` is empty or `currentSec` precedes the first entry. Pure;
|
||||
* safe to call every animation frame.
|
||||
*/
|
||||
export function activeLyricIndex(
|
||||
lyrics: readonly Lyric[],
|
||||
currentSec: number
|
||||
): number {
|
||||
if (lyrics.length === 0) return -1;
|
||||
if (currentSec < (lyrics[0] as Lyric).timestampSec) return -1;
|
||||
let lo = 0;
|
||||
let hi = lyrics.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >>> 1;
|
||||
if ((lyrics[mid] as Lyric).timestampSec <= currentSec) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Single parsed lyric line. `timestampSec` is seconds (not
|
||||
* milliseconds) for direct alignment with `transport.seconds` from
|
||||
* `@suaveplan/react-audio`. `text` is the trimmed lyric content
|
||||
* with the leading timestamp tag(s) stripped.
|
||||
*/
|
||||
export interface Lyric {
|
||||
readonly timestampSec: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface ParseLrcOptions {
|
||||
/**
|
||||
* Maximum input length in characters. Inputs exceeding this are
|
||||
* rejected with `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED`. Defaults
|
||||
* to 1 MiB which comfortably covers a 4-hour album-length LRC.
|
||||
*/
|
||||
readonly maxLength?: number;
|
||||
/**
|
||||
* If true (default), one source line containing N timestamps
|
||||
* (e.g. `[00:10][00:20]hello`) yields N output entries. If
|
||||
* false, only the first timestamp is honoured.
|
||||
*/
|
||||
readonly expandMultiTimestamp?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LyricsDisplay } from "./lyrics-display.js";
|
||||
export type { LyricsDisplayProps } from "./lyrics-display.types.js";
|
||||
@@ -0,0 +1,109 @@
|
||||
# `karaoke/lyrics-display`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<LyricsDisplay>` is the karaoke surface that renders a stack of
|
||||
lyric lines with the active line highlighted. It is the visible
|
||||
end-product of the `parseLrc` → `useLyrics` pipeline and the
|
||||
component the player variants compose when their `lrc` prop is
|
||||
populated.
|
||||
|
||||
The component is clock-agnostic by design: the caller passes
|
||||
`currentSec` (a plain number) and the component renders the
|
||||
highlighted line. The typical wiring is `useTransportTime()` from
|
||||
`@suaveplan/react-audio` when the display sits inside an
|
||||
`<AudioProvider>`, but standalone preview and test surfaces can
|
||||
hand in any number — Playwright spec runs use a deterministic
|
||||
clock; documentation pages drive a `<range>` input. Keeping the time
|
||||
source explicit removes a hidden provider dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders one `<span>` per lyric line with `data-active="true|false"`
|
||||
for CSS-driven styling; consumers needing structural control can
|
||||
pass `renderLine` for full custom rendering.
|
||||
- Active line carries `aria-live="polite"` by default so screen
|
||||
readers announce lyric changes; togglable via `announceLyrics`.
|
||||
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
|
||||
- Re-binds the active index every 100 ms (configurable) regardless
|
||||
of the consumer's render cadence.
|
||||
- Both time-source modes share the same internal render path; the
|
||||
outer dispatch is a one-time component-type pick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Player({ lrcSource }: { lrcSource: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
return (
|
||||
<LyricsDisplay
|
||||
lyrics={lyrics}
|
||||
currentSec={time}
|
||||
intervalMs={50}
|
||||
announceLyrics={false}
|
||||
renderLine={({ lyric, isActive }) => (
|
||||
<p
|
||||
style={{
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{lyric.text || <span aria-hidden="true">·</span>}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `currentSec: number` — required.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `announceLyrics?: boolean` (default `true`).
|
||||
- `className?: string`.
|
||||
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component is a single-branch render — no dual-component
|
||||
dispatch — because `currentSec` is always a plain prop. This keeps
|
||||
the Rules of Hooks trivially satisfied (one render path, one hook
|
||||
sequence) and removes a hidden coupling to the surrounding
|
||||
`<AudioProvider>`. Consumers that want the transport-driven path
|
||||
call `useTransportTime()` themselves and pass the result down.
|
||||
|
||||
The `data-active="true|false"` attribute is the recommended CSS
|
||||
selector hook. Toggling `aria-live` only on the active line ensures
|
||||
exactly one element narrates per change cycle; without that targeted
|
||||
gating, every line's render would re-announce on every re-mount,
|
||||
which is acoustically unusable.
|
||||
|
||||
When `renderLine` is supplied, the component delegates to it
|
||||
verbatim and skips the default `<span>` + `data-active` path. The
|
||||
delegated node is wrapped in a keyed `<Fragment>` so consumers can
|
||||
return any element they like — `<li>`, `<p>`, `<div>` — without
|
||||
fighting an enclosing wrapper.
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import { LyricsDisplay } from "./lyrics-display.js";
|
||||
|
||||
const SAMPLE = parseLrc(
|
||||
"[ar:Demo]\n[00:00.00]Verse one\n[00:08.00]Verse two\n[00:16.00]Chorus"
|
||||
);
|
||||
|
||||
const meta: Meta<typeof LyricsDisplay> = {
|
||||
title: "Karaoke/LyricsDisplay",
|
||||
component: LyricsDisplay,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LyricsDisplay>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
lyrics: SAMPLE,
|
||||
currentSec: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [t, setT] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.1}
|
||||
value={t}
|
||||
onChange={(e) => setT(Number(e.currentTarget.value))}
|
||||
aria-label="time"
|
||||
/>
|
||||
<LyricsDisplay lyrics={SAMPLE} currentSec={t} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeEmpty: Story = {
|
||||
args: {
|
||||
lyrics: [],
|
||||
currentSec: 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import { LyricsDisplay } from "./lyrics-display.js";
|
||||
|
||||
const LRC = "[00:00.00]a\n[00:10.00]b\n[00:20.00]c";
|
||||
const PARSED = parseLrc(LRC);
|
||||
|
||||
describe("<LyricsDisplay>", () => {
|
||||
it("renders all lines with role=region", { timeout: 5000 }, () => {
|
||||
render(<LyricsDisplay lyrics={PARSED} currentSec={0} />);
|
||||
expect(screen.getByRole("region", { name: "Lyrics" })).toBeDefined();
|
||||
expect(screen.getByText("a")).toBeDefined();
|
||||
expect(screen.getByText("b")).toBeDefined();
|
||||
expect(screen.getByText("c")).toBeDefined();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("flags the active line via data-active", { timeout: 5000 }, () => {
|
||||
render(<LyricsDisplay lyrics={PARSED} currentSec={12} />);
|
||||
const active = screen.getByText("b");
|
||||
expect(active.getAttribute("data-active")).toBe("true");
|
||||
const inactive = screen.getByText("a");
|
||||
expect(inactive.getAttribute("data-active")).toBe("false");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("active line carries aria-live=polite by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
render(<LyricsDisplay lyrics={PARSED} currentSec={12} />);
|
||||
const active = screen.getByText("b");
|
||||
expect(active.getAttribute("aria-live")).toBe("polite");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("announceLyrics=false suppresses aria-live", { timeout: 5000 }, () => {
|
||||
render(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={12}
|
||||
announceLyrics={false}
|
||||
/>
|
||||
);
|
||||
const active = screen.getByText("b");
|
||||
expect(active.getAttribute("aria-live")).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("custom renderLine delegates", { timeout: 5000 }, () => {
|
||||
render(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={5}
|
||||
renderLine={({ lyric, isActive }) => (
|
||||
<p data-testid={`line-${lyric.text}`}>
|
||||
{lyric.text}:{isActive ? "ON" : "OFF"}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("line-a").textContent).toBe("a:ON");
|
||||
expect(screen.getByTestId("line-b").textContent).toBe("b:OFF");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("accepts raw lrc source", { timeout: 5000 }, () => {
|
||||
render(<LyricsDisplay lrc={LRC} currentSec={0} />);
|
||||
expect(screen.getByText("a")).toBeDefined();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("custom className applies to wrapper", { timeout: 5000 }, () => {
|
||||
const { container } = render(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={0}
|
||||
className="my-lyrics"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".my-lyrics")).not.toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("updates active line on interval when currentSec rises", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<LyricsDisplay lyrics={PARSED} currentSec={5} intervalMs={50} />
|
||||
);
|
||||
expect(screen.getByText("a").getAttribute("data-active")).toBe(
|
||||
"true"
|
||||
);
|
||||
rerender(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={25}
|
||||
intervalMs={50}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60);
|
||||
});
|
||||
expect(screen.getByText("c").getAttribute("data-active")).toBe(
|
||||
"true"
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { useLyrics } from "../use-lyrics/use-lyrics.js";
|
||||
import type { LyricsDisplayProps } from "./lyrics-display.types.js";
|
||||
|
||||
/**
|
||||
* Karaoke surface. Renders a vertical list of lyric lines with the
|
||||
* currently-active line highlighted via `data-active="true"`. When
|
||||
* `announceLyrics` is true (default), the active line carries
|
||||
* `aria-live="polite"` so screen readers narrate it as the
|
||||
* transport advances.
|
||||
*
|
||||
* The component is intentionally clock-agnostic — it requires the
|
||||
* caller to supply `currentSec`. The typical wiring is
|
||||
* `useTransportTime()` from `@suaveplan/react-audio`; standalone
|
||||
* preview and test code can hand in any number. Keeping the time
|
||||
* source explicit avoids a hidden provider dependency and lets the
|
||||
* component render inside or outside `<AudioProvider>` without
|
||||
* branching its own internals.
|
||||
*/
|
||||
export function LyricsDisplay(props: LyricsDisplayProps): ReactElement {
|
||||
const announce = props.announceLyrics ?? true;
|
||||
const { lyrics, activeIndex } = useLyrics({
|
||||
...(props.lyrics !== undefined ? { lyrics: props.lyrics } : {}),
|
||||
...(props.lrc !== undefined ? { lrc: props.lrc } : {}),
|
||||
...(props.intervalMs !== undefined
|
||||
? { intervalMs: props.intervalMs }
|
||||
: {}),
|
||||
currentSec: props.currentSec,
|
||||
});
|
||||
const className = props.className ?? "lyrics-display";
|
||||
|
||||
return (
|
||||
<section className={className} aria-label="Lyrics">
|
||||
{lyrics.map((lyric, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const key = `${lyric.timestampSec}-${i}`;
|
||||
if (props.renderLine) {
|
||||
const node: ReactNode = props.renderLine({
|
||||
lyric,
|
||||
isActive,
|
||||
index: i,
|
||||
});
|
||||
return <Fragment key={key}>{node}</Fragment>;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
aria-live={isActive && announce ? "polite" : undefined}
|
||||
>
|
||||
{lyric.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
|
||||
|
||||
export interface LyricsDisplayProps {
|
||||
/** Pre-parsed lyrics. Either this or `lrc` must be supplied. */
|
||||
readonly lyrics?: readonly Lyric[];
|
||||
/** Raw LRC source — parsed once per identity change. */
|
||||
readonly lrc?: string;
|
||||
/**
|
||||
* Current time in seconds. The caller is responsible for sourcing
|
||||
* this — typically `useTransportTime()` from `@suaveplan/react-audio`
|
||||
* when used inside `<AudioProvider>`, or any external clock when
|
||||
* used standalone (test preview, documentation pages, etc.).
|
||||
* Keeping this prop explicit avoids a hidden provider dependency
|
||||
* and lets the component render in or out of a provider.
|
||||
*/
|
||||
readonly currentSec: number;
|
||||
/** Resample interval in milliseconds. Default 100. */
|
||||
readonly intervalMs?: number;
|
||||
/**
|
||||
* When true (default), the active line carries `aria-live="polite"`
|
||||
* so screen readers announce it as it changes.
|
||||
*/
|
||||
readonly announceLyrics?: boolean;
|
||||
/** Forwarded to the wrapping `<div>` for styling. */
|
||||
readonly className?: string;
|
||||
/**
|
||||
* Render-prop hook for custom line rendering. Receives `{ lyric,
|
||||
* isActive, index }`. When absent, the component renders a
|
||||
* `<span>` per line with `data-active="true|false"` attributes.
|
||||
*/
|
||||
readonly renderLine?: (args: {
|
||||
readonly lyric: Lyric;
|
||||
readonly isActive: boolean;
|
||||
readonly index: number;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useLyrics } from "./use-lyrics.js";
|
||||
export type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";
|
||||
@@ -0,0 +1,121 @@
|
||||
# `karaoke/use-lyrics`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the
|
||||
active-line state, samples it at a fixed interval (100 ms by
|
||||
default), and returns `{ lyrics, activeIndex, activeLyric }`. The
|
||||
consumer hands in either pre-parsed `Lyric[]` or raw LRC source,
|
||||
plus the current playback time in seconds. The hook deliberately
|
||||
does not call `useTransportTime` itself so it can be driven from any
|
||||
clock — `<LyricsDisplay>` wires the transport in for you, but a
|
||||
preview panel running under a deterministic test clock can call this
|
||||
hook directly.
|
||||
|
||||
The 100 ms re-bind ceiling matches the spec requirement that the
|
||||
active lyric advance within 100 ms of the transport. Sampling at
|
||||
that cadence (rather than per-frame) saves a substantial amount of
|
||||
React commit work on songs with hundreds of lyric lines.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure: no audio context, no global side-effect.
|
||||
- Pre-parsed `lyrics` or raw `lrc` source — both accepted; if both
|
||||
provided, `lyrics` wins (no double work).
|
||||
- Configurable `intervalMs` (default 100). The hook re-evaluates
|
||||
the active line on each tick and only commits when the index
|
||||
actually changes — silent renders cost nothing.
|
||||
- Returns `activeLyric: undefined` before the first cue or when
|
||||
`lyrics` is empty.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useLyrics } from "@suaveplan/react-audio-player/karaoke/use-lyrics";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Lyrics({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
const { activeLyric } = useLyrics({ lrc, currentSec: seconds });
|
||||
return <span aria-live="polite">{activeLyric?.text ?? "…"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
useLyrics,
|
||||
} from "@suaveplan/react-audio-player/karaoke";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function StandaloneLyricPreview({ lrc }: { lrc: string }) {
|
||||
// Parse once at the call site rather than on every mount.
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
// Deterministic clock — no <AudioProvider> required.
|
||||
const [tick, setTick] = useState(0);
|
||||
const { activeIndex, activeLyric, lyrics: ls } = useLyrics({
|
||||
lyrics,
|
||||
currentSec: tick * 0.5,
|
||||
intervalMs: 50,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setTick((t) => t + 1)}>
|
||||
+0.5 s
|
||||
</button>
|
||||
<ol>
|
||||
{ls.map((l, i) => (
|
||||
<li
|
||||
key={`${l.timestampSec}-${i}`}
|
||||
data-active={i === activeIndex}
|
||||
>
|
||||
{l.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p>Active: {activeLyric?.text ?? "—"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useLyrics(opts: UseLyricsOptions): UseLyricsResult`
|
||||
- `UseLyricsOptions`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `currentSec: number` — required.
|
||||
- `UseLyricsResult`:
|
||||
- `lyrics: readonly Lyric[]`,
|
||||
- `activeIndex: number` (`-1` for "before first cue / empty"),
|
||||
- `activeLyric: Lyric | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook owns one piece of React state — the active index — and one
|
||||
`setInterval`. Both `lyrics` and `currentSec` are mirrored through
|
||||
refs so the interval callback always sees the latest values without
|
||||
re-binding the timer on every render. The interval only fires
|
||||
`setActiveIndex` when the recomputed index actually differs, which
|
||||
is the standard React micro-optimisation for high-frequency external
|
||||
clocks.
|
||||
|
||||
`activeLyricIndex` (from the sibling `lrc-parser` module) is a
|
||||
binary search; combined with the 100 ms sampling cadence, the hook's
|
||||
steady-state work per frame is one comparison plus a "same index?"
|
||||
branch. Long-form lyrics (10k+ lines) are not measurably more
|
||||
expensive than short ones.
|
||||
|
||||
The hook lazily initialises its `activeIndex` state from
|
||||
`activeLyricIndex(lyrics, currentSec)` so the first render — before
|
||||
the effect mounts — already reflects the correct active line. This
|
||||
matters for SSR-rendered previews and snapshot tests that read the
|
||||
hook's output without ever ticking the interval.
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import { useLyrics } from "./use-lyrics.js";
|
||||
|
||||
const LYRICS = parseLrc("[00:00.00]a\n[00:10.00]b\n[00:20.00]c\n[00:30.00]d");
|
||||
|
||||
describe("useLyrics", () => {
|
||||
it("derives initial active index from currentSec", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({ lyrics: LYRICS, currentSec: 15 })
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
expect(result.current.activeLyric?.text).toBe("b");
|
||||
});
|
||||
|
||||
it("returns -1 / undefined before first cue", { timeout: 5000 }, () => {
|
||||
const earlier = parseLrc("[00:05]a");
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({ lyrics: earlier, currentSec: 0 })
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(-1);
|
||||
expect(result.current.activeLyric).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses raw lrc source", { timeout: 5000 }, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({ lrc: "[00:01.00]x", currentSec: 5 })
|
||||
);
|
||||
expect(result.current.lyrics.length).toBe(1);
|
||||
expect(result.current.activeLyric?.text).toBe("x");
|
||||
});
|
||||
|
||||
it("no lyrics + no lrc → empty list", { timeout: 5000 }, () => {
|
||||
const { result } = renderHook(() => useLyrics({ currentSec: 0 }));
|
||||
expect(result.current.lyrics).toEqual([]);
|
||||
expect(result.current.activeIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("updates active index on rerender when currentSec changes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ t }: { t: number }) =>
|
||||
useLyrics({ lyrics: LYRICS, currentSec: t }),
|
||||
{ initialProps: { t: 5 } }
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
rerender({ t: 25 });
|
||||
// Even before the interval fires, the next interval tick will
|
||||
// pick it up; we advance time to confirm.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// Re-render schedules the effect; advance fake timers to
|
||||
// run it.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(101);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect([1, 2]).toContain(result.current.activeIndex);
|
||||
});
|
||||
|
||||
it("samples on interval", { timeout: 5000 }, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let t = 0;
|
||||
const { result, rerender } = renderHook(
|
||||
({ x }: { x: number }) =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
currentSec: x,
|
||||
intervalMs: 50,
|
||||
}),
|
||||
{ initialProps: { x: t } }
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
t = 25;
|
||||
rerender({ x: t });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60);
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("interval clamps to 1 ms and cleans up on unmount", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
currentSec: 5,
|
||||
intervalMs: 0,
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5);
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
// No throw on cleanup.
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lyrics prop wins over lrc prop when both supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
lrc: "[00:01]ignored",
|
||||
currentSec: 15,
|
||||
})
|
||||
);
|
||||
expect(result.current.lyrics).toBe(LYRICS);
|
||||
expect(result.current.activeLyric?.text).toBe("b");
|
||||
});
|
||||
|
||||
it("no-op tick when index hasn't changed", { timeout: 5000 }, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
currentSec: 5,
|
||||
intervalMs: 20,
|
||||
})
|
||||
);
|
||||
const idx = result.current.activeIndex;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(idx);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { activeLyricIndex, parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
|
||||
import type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* Engine-agnostic karaoke hook. Accepts a pre-parsed `lyrics` array
|
||||
* (preferred) or a raw `lrc` string, plus a `currentSec` value, and
|
||||
* returns the active line. Sampling cadence is decoupled from the
|
||||
* caller's render rate: a 100 ms interval (configurable) forces a
|
||||
* re-render even when neither `lyrics` nor `currentSec` mutate.
|
||||
*
|
||||
* The hook is deliberately decoupled from `useTransportTime` so it
|
||||
* can be driven from any clock — `<LyricsDisplay>` wires the
|
||||
* transport in for you when used inside `<AudioProvider>`, but a
|
||||
* preview panel running under a deterministic test clock can call
|
||||
* this hook directly with a hand-fed `currentSec`.
|
||||
*/
|
||||
export function useLyrics(opts: UseLyricsOptions): UseLyricsResult {
|
||||
const lyrics: readonly Lyric[] = useMemo(() => {
|
||||
if (opts.lyrics) return opts.lyrics;
|
||||
if (typeof opts.lrc === "string") return parseLrc(opts.lrc);
|
||||
return [];
|
||||
}, [opts.lyrics, opts.lrc]);
|
||||
|
||||
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
|
||||
// A heartbeat counter forces a re-render every `intervalMs` so
|
||||
// the memo below recomputes even when `currentSec` did not
|
||||
// change between commits — relevant when the consumer's clock
|
||||
// mutates a ref or external store the hook cannot subscribe to.
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(
|
||||
() => {
|
||||
setTick((t) => (t + 1) % 1_000_000);
|
||||
},
|
||||
Math.max(1, intervalMs)
|
||||
);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
|
||||
const activeIndex = useMemo(
|
||||
() => activeLyricIndex(lyrics, opts.currentSec),
|
||||
[lyrics, opts.currentSec]
|
||||
);
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
activeIndex,
|
||||
activeLyric: activeIndex >= 0 ? lyrics[activeIndex] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
|
||||
|
||||
export interface UseLyricsOptions {
|
||||
/**
|
||||
* Pre-parsed lyric list. Either `lyrics` or `lrc` must be
|
||||
* supplied; if both are present, `lyrics` wins.
|
||||
*/
|
||||
readonly lyrics?: readonly Lyric[];
|
||||
/** Raw LRC source. Parsed once per identity change. */
|
||||
readonly lrc?: string;
|
||||
/**
|
||||
* Active-line resample interval in milliseconds. Defaults to 100
|
||||
* ms — the spec ceiling for "active lyric must advance within 100
|
||||
* ms of the transport time".
|
||||
*/
|
||||
readonly intervalMs?: number;
|
||||
/**
|
||||
* Current time in seconds. The consumer is responsible for
|
||||
* sourcing this — typically from `useTransportTime()` in
|
||||
* `@suaveplan/react-audio`, but any clock works. The hook
|
||||
* re-evaluates the active line every `intervalMs` regardless of
|
||||
* how often this value mutates between renders.
|
||||
*/
|
||||
readonly currentSec: number;
|
||||
}
|
||||
|
||||
export interface UseLyricsResult {
|
||||
readonly lyrics: readonly Lyric[];
|
||||
/** `-1` before the first cue or when `lyrics` is empty. */
|
||||
readonly activeIndex: number;
|
||||
/** Convenience: `lyrics[activeIndex]` or `undefined`. */
|
||||
readonly activeLyric: Lyric | undefined;
|
||||
}
|
||||
46
packages/ui/react/react-audio-player/tests/exports.test.ts
Normal file
46
packages/ui/react/react-audio-player/tests/exports.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
const PKG_JSON = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown>; dependencies: Record<string, string> };
|
||||
|
||||
const REQUIRED_SURFACES = [
|
||||
".",
|
||||
"./karaoke",
|
||||
"./karaoke/lrc-parser",
|
||||
"./karaoke/use-lyrics",
|
||||
"./karaoke/lyrics-display",
|
||||
"./error-codes",
|
||||
];
|
||||
|
||||
describe("subpath exports", () => {
|
||||
it("every public surface has a subpath entry", { timeout: 5000 }, () => {
|
||||
for (const surface of REQUIRED_SURFACES) {
|
||||
expect(PKG_JSON.exports[surface]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("sideEffects=false", { timeout: 5000 }, () => {
|
||||
const raw = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { sideEffects: boolean };
|
||||
expect(raw.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("runtime dependency set matches allowlist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = Object.keys(PKG_JSON.dependencies).sort();
|
||||
expect(deps).toEqual(
|
||||
[
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/react-audio",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
38
packages/ui/react/react-audio-player/tests/facade.test.tsx
Normal file
38
packages/ui/react/react-audio-player/tests/facade.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as RAP from "../src/index.js";
|
||||
|
||||
describe("@suaveplan/react-audio-player root barrel", () => {
|
||||
it("exposes the namespaced ReactAudio re-export", { timeout: 5000 }, () => {
|
||||
expect(RAP.ReactAudio).toBeDefined();
|
||||
// The namespace must include the player components.
|
||||
expect(typeof RAP.ReactAudio.AudioProvider).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.AudioPlayer).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.useTransport).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.useAnalyser).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.usePlaylist).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes the flat alias façade", { timeout: 5000 }, () => {
|
||||
expect(typeof RAP.AudioPlayer).toBe("function");
|
||||
expect(typeof RAP.AudioProvider).toBe("function");
|
||||
expect(typeof RAP.BasicPlayer).toBe("function");
|
||||
expect(typeof RAP.PremiumPlayer).toBe("function");
|
||||
expect(typeof RAP.PresetManager).toBe("function");
|
||||
expect(typeof RAP.TransportControls).toBe("function");
|
||||
expect(typeof RAP.useAnalyser).toBe("function");
|
||||
expect(typeof RAP.usePlaylist).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes karaoke surface from root", { timeout: 5000 }, () => {
|
||||
expect(typeof RAP.parseLrc).toBe("function");
|
||||
expect(typeof RAP.activeLyricIndex).toBe("function");
|
||||
expect(typeof RAP.useLyrics).toBe("function");
|
||||
expect(typeof RAP.LyricsDisplay).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes error-code surface from root", { timeout: 5000 }, () => {
|
||||
expect(RAP.REACT_AUDIO_PLAYER_CODES).toBeDefined();
|
||||
expect(typeof RAP.ReactAudioPlayerError).toBe("function");
|
||||
expect(typeof RAP.registerReactAudioPlayerErrorCodes).toBe("function");
|
||||
});
|
||||
});
|
||||
19
packages/ui/react/react-audio-player/tsconfig.lib.json
Normal file
19
packages/ui/react/react-audio-player/tsconfig.lib.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../foundation/core/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
54
packages/ui/react/react-audio-player/vite.config.ts
Normal file
54
packages/ui/react/react-audio-player/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/react-audio-player",
|
||||
plugins: [
|
||||
react(),
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"karaoke/index": "src/karaoke/index.ts",
|
||||
"karaoke/lrc-parser/lrc-parser":
|
||||
"src/karaoke/lrc-parser/lrc-parser.ts",
|
||||
"karaoke/use-lyrics/use-lyrics":
|
||||
"src/karaoke/use-lyrics/use-lyrics.ts",
|
||||
"karaoke/lyrics-display/lyrics-display":
|
||||
"src/karaoke/lyrics-display/lyrics-display.tsx",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/react-audio",
|
||||
"@suaveplan/types",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"tslib",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
42
packages/ui/react/react-audio-player/vitest.config.ts
Normal file
42
packages/ui/react/react-audio-player/vitest.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
esbuild: {
|
||||
legalComments: "inline",
|
||||
},
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "happy-dom",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/**/*.test.tsx",
|
||||
],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
3
packages/ui/react/react-audio-stream/.gitignore
vendored
Normal file
3
packages/ui/react/react-audio-stream/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
coverage/
|
||||
dist/
|
||||
node_modules/
|
||||
132
packages/ui/react/react-audio-stream/README.md
Normal file
132
packages/ui/react/react-audio-stream/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
|
||||
|
||||
React 19 surface for live audio streaming sessions: a Provider, six hooks, and four
|
||||
accessible components. Chunk C of the four-chunk `enterprise-react-audio-stream` split.
|
||||
|
||||
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
|
||||
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
|
||||
> consumers actually touch.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
|
||||
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
|
||||
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
|
||||
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
|
||||
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
|
||||
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Provider
|
||||
|
||||
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
|
||||
`StreamSession` lazily; fans seven session event channels into a single render-bump
|
||||
counter; disconnects on unmount.
|
||||
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
|
||||
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
|
||||
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
|
||||
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
|
||||
`disconnect`.
|
||||
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
|
||||
- `useStreamPeers()` — live `readonly Peer[]`.
|
||||
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
|
||||
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
|
||||
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
|
||||
`setTrackSolo`.
|
||||
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
|
||||
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
|
||||
`RecordingResult` blob.
|
||||
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
|
||||
|
||||
## Components
|
||||
|
||||
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
|
||||
leave callbacks.
|
||||
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
|
||||
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
|
||||
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
|
||||
read-out.
|
||||
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
|
||||
nothing when permission is granted.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
|
||||
|
||||
function Room() {
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
|
||||
return <p>Joined!</p>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createMyStreamSession()}>
|
||||
<Room />
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints (from the canonical spec)
|
||||
|
||||
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
|
||||
- DAW solo semantics live in the mixer hook, not in each component.
|
||||
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
145
packages/ui/react/react-audio-stream/package.json
Normal file
145
packages/ui/react/react-audio-stream/package.json
Normal file
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"name": "@suaveplan/react-audio-stream",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./session": {
|
||||
"import": {
|
||||
"types": "./dist/session/index.d.ts",
|
||||
"default": "./dist/session/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./transports": {
|
||||
"import": {
|
||||
"types": "./dist/transports/index.d.ts",
|
||||
"default": "./dist/transports/index.js"
|
||||
}
|
||||
},
|
||||
"./transports/webrtc": {
|
||||
"import": {
|
||||
"types": "./dist/transports/webrtc/index.d.ts",
|
||||
"default": "./dist/transports/webrtc/index.js"
|
||||
}
|
||||
},
|
||||
"./transports/websocket": {
|
||||
"import": {
|
||||
"types": "./dist/transports/websocket/index.d.ts",
|
||||
"default": "./dist/transports/websocket/index.js"
|
||||
}
|
||||
},
|
||||
"./transports/trystero": {
|
||||
"import": {
|
||||
"types": "./dist/transports/trystero/index.d.ts",
|
||||
"default": "./dist/transports/trystero/index.js"
|
||||
}
|
||||
},
|
||||
"./provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/index.d.ts",
|
||||
"default": "./dist/provider/index.js"
|
||||
}
|
||||
},
|
||||
"./hooks": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/index.d.ts",
|
||||
"default": "./dist/hooks/index.js"
|
||||
}
|
||||
},
|
||||
"./components": {
|
||||
"import": {
|
||||
"types": "./dist/components/index.d.ts",
|
||||
"default": "./dist/components/index.js"
|
||||
}
|
||||
},
|
||||
"./transport-share": {
|
||||
"import": {
|
||||
"types": "./dist/transport-share/index.d.ts",
|
||||
"default": "./dist/transport-share/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/audio-transport": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/fsm": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/react-audio": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"zustand": "catalog:state",
|
||||
"@suaveplan/network-transport-websockets": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@suaveplan/network-discovery-trystero": "workspace:^",
|
||||
"@suaveplan/audio-codec-ffmpeg-wasm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
17
packages/ui/react/react-audio-stream/src/components/index.ts
Normal file
17
packages/ui/react/react-audio-stream/src/components/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
StreamMixer,
|
||||
type StreamMixerProps,
|
||||
} from "./stream-mixer/stream-mixer.js";
|
||||
export {
|
||||
StreamPermissionsBanner,
|
||||
type StreamPermissionsBannerProps,
|
||||
} from "./stream-permissions-banner/stream-permissions-banner.js";
|
||||
export {
|
||||
StreamRecorder,
|
||||
type StreamRecorderProps,
|
||||
} from "./stream-recorder/stream-recorder.js";
|
||||
export {
|
||||
StreamRoom,
|
||||
type StreamRoomProps,
|
||||
type StreamRoomRenderProps,
|
||||
} from "./stream-room/stream-room.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SharedTransportIndicator } from "./shared-transport-indicator.js";
|
||||
export type { SharedTransportIndicatorProps } from "./shared-transport-indicator.types.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# SharedTransportIndicator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
|
||||
|
||||
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
|
||||
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
|
||||
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
|
||||
- Optional `label` override (string or any `ReactNode`).
|
||||
- Optional `className` for layout integration.
|
||||
- Spec-aligned default label: `"Shared transport active"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
|
||||
|
||||
function StatusBar(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return <SharedTransportIndicator state={share.state} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function MixerHeader(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return (
|
||||
<header className="mixer-header">
|
||||
<SharedTransportIndicator
|
||||
state={share.state}
|
||||
className="mixer-header__share-badge"
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
label={
|
||||
share.state.role === "driving"
|
||||
? `Driving ${share.state.receivers.length} peer(s)`
|
||||
: `Receiving from ${share.state.driverId}`
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SharedTransportIndicator(props): ReactNode`
|
||||
- `SharedTransportIndicatorProps`
|
||||
- `state: ShareState` — required.
|
||||
- `className?: string` — optional root class.
|
||||
- `label?: ReactNode` — optional label override.
|
||||
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.
|
||||
@@ -0,0 +1,66 @@
|
||||
/* istanbul ignore file -- story-only fixtures */
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { ShareState } from "../../transport-share/index.js";
|
||||
import { SharedTransportIndicator } from "./shared-transport-indicator.js";
|
||||
|
||||
const meta = {
|
||||
title: "react-audio-stream/SharedTransportIndicator",
|
||||
component: SharedTransportIndicator,
|
||||
} satisfies Meta<typeof SharedTransportIndicator>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const localState: ShareState = {
|
||||
role: "local",
|
||||
driverId: undefined,
|
||||
receivers: [],
|
||||
};
|
||||
const drivingState: ShareState = {
|
||||
role: "driving",
|
||||
driverId: "self",
|
||||
receivers: ["alice", "bob"],
|
||||
};
|
||||
const receivingState: ShareState = {
|
||||
role: "receiving",
|
||||
driverId: "alice",
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
state: receivingState,
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
state: localState,
|
||||
},
|
||||
};
|
||||
|
||||
export const Driving: Story = {
|
||||
args: {
|
||||
state: drivingState,
|
||||
visibleRoles: ["driving", "receiving"],
|
||||
label: "Driving 2 peers",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomLabel: Story = {
|
||||
args: {
|
||||
state: receivingState,
|
||||
label: "Locked to alice",
|
||||
className: "share-badge--accent",
|
||||
},
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
state: receivingState,
|
||||
label: "Shared transport active",
|
||||
visibleRoles: ["receiving"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ShareState } from "../../transport-share/index.js";
|
||||
import { SharedTransportIndicator } from "./shared-transport-indicator.js";
|
||||
|
||||
const localState: ShareState = {
|
||||
role: "local",
|
||||
driverId: undefined,
|
||||
receivers: [],
|
||||
};
|
||||
const drivingState: ShareState = {
|
||||
role: "driving",
|
||||
driverId: "self",
|
||||
receivers: ["alice"],
|
||||
};
|
||||
const receivingState: ShareState = {
|
||||
role: "receiving",
|
||||
driverId: "alice",
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
describe("SharedTransportIndicator", () => {
|
||||
it("renders nothing when the local peer is not receiving", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator state={localState} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the default label and aria-live when receiving", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator state={receivingState} />
|
||||
);
|
||||
const el = container.querySelector("[role='status']");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.textContent).toBe("Shared transport active");
|
||||
expect(el?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(el?.getAttribute("data-share-role")).toBe("receiving");
|
||||
expect(el?.getAttribute("data-driver-id")).toBe("alice");
|
||||
});
|
||||
|
||||
it("honours a custom label", { timeout: 5000 }, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={receivingState}
|
||||
label="Locked to alice"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("[role='status']")?.textContent).toBe(
|
||||
"Locked to alice"
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the supplied className", { timeout: 5000 }, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={receivingState}
|
||||
className="my-badge"
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
container.querySelector("[role='status']")?.getAttribute("class")
|
||||
).toBe("my-badge");
|
||||
});
|
||||
|
||||
it("renders for driving role when visibleRoles includes it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={drivingState}
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
/>
|
||||
);
|
||||
const el = container.querySelector("[role='status']");
|
||||
expect(el?.getAttribute("data-share-role")).toBe("driving");
|
||||
});
|
||||
|
||||
it("does not render for receiving role when visibleRoles excludes it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={receivingState}
|
||||
visibleRoles={["driving"]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes an empty data-driver-id when driverId is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const odd: ShareState = {
|
||||
role: "receiving",
|
||||
driverId: undefined,
|
||||
receivers: [],
|
||||
};
|
||||
const { container } = render(<SharedTransportIndicator state={odd} />);
|
||||
expect(
|
||||
container
|
||||
.querySelector("[role='status']")
|
||||
?.getAttribute("data-driver-id")
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SharedTransportIndicatorProps } from "./shared-transport-indicator.types.js";
|
||||
|
||||
const DEFAULT_LABEL = "Shared transport active";
|
||||
const DEFAULT_VISIBLE_ROLES = ["receiving"] as const;
|
||||
|
||||
/**
|
||||
* Renders the spec-mandated "Shared transport active" indicator when the
|
||||
* local peer is in `'receiving'` mode (or any role listed in
|
||||
* `visibleRoles`). The element is announced via `aria-live="polite"` and
|
||||
* carries the `data-share-role` attribute so callers can style it
|
||||
* differently per role without prop drilling.
|
||||
*/
|
||||
export function SharedTransportIndicator(
|
||||
props: SharedTransportIndicatorProps
|
||||
): ReactNode {
|
||||
const { state, className, label, visibleRoles } = props;
|
||||
const roles = visibleRoles ?? DEFAULT_VISIBLE_ROLES;
|
||||
if (!roles.includes(state.role)) {
|
||||
return null;
|
||||
}
|
||||
const display = label ?? DEFAULT_LABEL;
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={className}
|
||||
data-share-role={state.role}
|
||||
data-driver-id={state.driverId ?? ""}
|
||||
role="status"
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ShareRole, ShareState } from "../../transport-share/index.js";
|
||||
|
||||
/**
|
||||
* Props for {@link SharedTransportIndicator}.
|
||||
*
|
||||
* The component is a presentational wrapper — it accepts the current share
|
||||
* state from the caller (typically `useTransportShare().state`) and renders
|
||||
* the spec-mandated "Shared transport active" indicator when the local peer
|
||||
* is in `'receiving'` mode.
|
||||
*/
|
||||
export interface SharedTransportIndicatorProps {
|
||||
/** Current share-state snapshot. */
|
||||
readonly state: ShareState;
|
||||
/**
|
||||
* Optional class name forwarded to the rendered root element. The
|
||||
* component renders nothing when the local peer is not receiving; this
|
||||
* class only applies to the `'receiving'` branch.
|
||||
*/
|
||||
readonly className?: string;
|
||||
/**
|
||||
* Optional override for the visible label. Defaults to
|
||||
* `"Shared transport active"` per the spec. The supplied node is also
|
||||
* announced to assistive technology via `aria-live="polite"`.
|
||||
*/
|
||||
readonly label?: ReactNode;
|
||||
/**
|
||||
* Roles to display the indicator for. Defaults to `['receiving']`. Set
|
||||
* to `['driving', 'receiving']` to also render an "I'm sharing" badge
|
||||
* on the driver side.
|
||||
*/
|
||||
readonly visibleRoles?: ReadonlyArray<ShareRole>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user