Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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 -->

View File

@@ -0,0 +1 @@
React hooks and components for audio, auth, and UI surfaces.

View 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.

View 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"
]
}

View File

@@ -0,0 +1,2 @@
export { LocalBackend } from "./local-backend/local-backend.js";
export type { FingerprintBackend } from "./local-backend/local-backend.types.js";

View File

@@ -0,0 +1,2 @@
export { LocalBackend } from "./local-backend.js";
export type { FingerprintBackend } from "./local-backend.types.js";

View File

@@ -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.

View File

@@ -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([]);
});
});

View File

@@ -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);
}
}

View File

@@ -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[]>;
}

View File

@@ -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.

View File

@@ -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}]`,
},
};

View File

@@ -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%");
});
});

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
export { FingerprintMatchDisplay } from "./fingerprint-match-display.js";
export type { FingerprintMatchDisplayProps } from "./fingerprint-match-display.types.js";

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export { MemoryCorpus } from "./memory-corpus/memory-corpus.js";
export type { Corpus } from "./memory-corpus/memory-corpus.types.js";

View File

@@ -0,0 +1,2 @@
export { MemoryCorpus } from "./memory-corpus.js";
export type { Corpus } from "./memory-corpus.types.js";

View File

@@ -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.

View File

@@ -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
);
}
});
});

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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";

View File

@@ -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.

View File

@@ -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
);
}
});
});

View File

@@ -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,
};
}
}

View File

@@ -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>;
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
export {
buildConstellation,
type ConstellationOptionsInput,
ConstellationOptionsSchema,
packHash,
unpackHash,
} from "./constellation.js";
export type {
ConstellationOptions,
Fingerprint,
FingerprintPair,
} from "./constellation.types.js";

View File

@@ -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)`.

View File

@@ -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
);
}
});
});

View File

@@ -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,
};
}
}

View File

@@ -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>;
}

View File

@@ -0,0 +1,7 @@
export { FingerprintMatcher } from "./fingerprint-matcher.js";
export type {
CorpusLike,
FingerprintCorpusEntry,
MatchOneResult,
MatchResult,
} from "./fingerprint-matcher.types.js";

View File

@@ -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";

View File

@@ -0,0 +1,6 @@
export {
detectPeaks,
type PeakDetectorOptionsInput,
PeakDetectorOptionsSchema,
} from "./peak-detector.js";
export type { Peak, PeakDetectorOptions } from "./peak-detector.types.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -0,0 +1,7 @@
export { useAudioFingerprint } from "./use-audio-fingerprint.js";
export type {
FingerprintState,
UseAudioFingerprintOptions,
UseAudioFingerprintResult,
UseAudioFingerprintSource,
} from "./use-audio-fingerprint.types.js";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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 };
}

View File

@@ -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;

View 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";

View 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`.

View File

@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View 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;
}

View 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"
]
}

View 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:/,
],
},
},
});

View 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,
},
},
},
});

View 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.

View 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"
]
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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();

View File

@@ -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";

View 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";

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export { activeLyricIndex, parseLrc } from "./lrc-parser.js";
export type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";

View File

@@ -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).

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
export { LyricsDisplay } from "./lyrics-display.js";
export type { LyricsDisplayProps } from "./lyrics-display.types.js";

View File

@@ -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.

View File

@@ -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,
},
};

View File

@@ -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();
}
});
});

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
export { useLyrics } from "./use-lyrics.js";
export type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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,
};
}

View File

@@ -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;
}

View 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()
);
});
});

View 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");
});
});

View 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"
]
}

View 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:/,
],
},
},
});

View 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,
},
});

View File

@@ -0,0 +1,3 @@
coverage/
dist/
node_modules/

View 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>`.

View 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"
]
}

View 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";

View File

@@ -0,0 +1,2 @@
export { SharedTransportIndicator } from "./shared-transport-indicator.js";
export type { SharedTransportIndicatorProps } from "./shared-transport-indicator.types.js";

View File

@@ -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.

View File

@@ -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"],
},
};

View File

@@ -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("");
});
});

View File

@@ -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>
);
}

View File

@@ -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