first commit
This commit is contained in:
21
docs/ui/react/README.md
Normal file
21
docs/ui/react/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# `react`
|
||||
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `react`
|
||||
|
||||
9 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
|
||||
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
|
||||
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
|
||||
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
|
||||
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
|
||||
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
|
||||
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
|
||||
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
|
||||
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/ui/react/_intro.md
Normal file
1
docs/ui/react/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
91
docs/ui/react/react-audio-fingerprint/README.md
Normal file
91
docs/ui/react/react-audio-fingerprint/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/react-audio-fingerprint - React 19 surface for client-side audio fingerprinting (Shazam-style constellation algorithm) and identity matching. Consumes FFT/spectrogram primitives from @suaveplan/audio-analysis-spectrum so the audio family has a single FFT implementation. Universal fingerprint + matcher cores; React hook + accessible component on top.
|
||||
|
||||
React 19 surface for client-side audio fingerprinting and identity matching.
|
||||
The package implements the classic Shazam-style constellation algorithm
|
||||
(2D-peak detection + pair hashing + time-offset histogram matching) on top of
|
||||
the FFT/spectrogram primitives from
|
||||
[`@suaveplan/audio-analysis-spectrum`](../../../media/audio/audio-analysis-spectrum)
|
||||
— there is exactly **one** FFT implementation across the audio family.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
|
||||
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
|
||||
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
|
||||
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
|
||||
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
|
||||
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
|
||||
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
|
||||
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
|
||||
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
|
||||
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
|
||||
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
|
||||
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
|
||||
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
|
||||
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
|
||||
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
|
||||
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
|
||||
- [test-setup](./src/test-setup.md) — This file is loaded by `vitest.config.ts` via `setupFiles` so every test in the package runs against a consistent jest-…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
bootstrap landing — initial slice focused on:
|
||||
|
||||
- Six error codes via `defineCodes("REACT_AUDIO_FINGERPRINT", …)`.
|
||||
- `peak-detector`, `constellation`, `audio-fingerprinter`.
|
||||
- `fingerprint-matcher` (time-offset histogramming).
|
||||
- `memory-corpus` (`Map`-backed, async-shaped, subscribable).
|
||||
- `local-backend` (default `FingerprintBackend` running in-process).
|
||||
- `useAudioFingerprint` hook (driver with abort + progress).
|
||||
- `<FingerprintMatchDisplay>` component + stories.
|
||||
|
||||
Deferred to follow-up: `IndexedDbCorpus`, `<AudioFingerprintUploader>`,
|
||||
`<FingerprintVisualizer>`, `python-backend`, `__benchmarks__`, integration +
|
||||
purity + property + bundle-size tests.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import { decode } from "@suaveplan/audio-engine-browser/decode";
|
||||
import {
|
||||
LocalBackend,
|
||||
MemoryCorpus,
|
||||
FingerprintMatchDisplay,
|
||||
useAudioFingerprint,
|
||||
} from "@suaveplan/react-audio-fingerprint";
|
||||
|
||||
function MatchSurface({ file }: { file: File }) {
|
||||
const [doc, setDoc] = useState<AudioDocument | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
decode(file).then(setDoc);
|
||||
}, [file]);
|
||||
const { state, fingerprint } = useAudioFingerprint(doc);
|
||||
return state === "done"
|
||||
? <FingerprintMatchDisplay results={[]} />
|
||||
: <progress />;
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
`AudioFingerprinter` composes three deterministic stages:
|
||||
|
||||
1. **Spectrogram** (delegated to `@suaveplan/audio-analysis-spectrum`).
|
||||
2. **2D peak detection** (`peak-detector`) — local-max sweep above the
|
||||
`noiseFloor`, capped at `peaksPerFrame` per frame.
|
||||
3. **Constellation pair hashing** (`constellation`) — 32-bit packed
|
||||
`binA[12] | binB[12] | dt[8]` over each anchor's `fanout` next peaks
|
||||
inside a `targetZoneSec` window.
|
||||
|
||||
Matching is identical to the original Wang (2003) paper: build a 10 ms-wide
|
||||
histogram of `(candidateOffset − queryOffset)` per shared hash, return the
|
||||
heaviest bin's count as the score and its midpoint as the alignment offset.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE` at the repo root.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# `local-backend` — Default in-process `FingerprintBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LocalBackend` is the default implementation of the `FingerprintBackend`
|
||||
contract that hooks and components depend on. It composes `AudioFingerprinter`
|
||||
and `FingerprintMatcher` so callers get a single facade with the same shape
|
||||
the optional `PythonBackend` exposes from its gated subpath. The class owns
|
||||
no state apart from its two collaborator instances.
|
||||
|
||||
## Features
|
||||
|
||||
- Single facade for `fingerprint(doc, opts?)` and `match(query, corpus)`.
|
||||
- `AbortSignal` and progress passthrough to the underlying fingerprinter.
|
||||
- Pure in-process: no I/O, no fetch, no server dependency.
|
||||
- Matches the `FingerprintBackend` interface byte-for-byte so it can be
|
||||
swapped at runtime via a hook prop.
|
||||
- Constructor takes no arguments — defaults rule.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
|
||||
|
||||
const backend = new LocalBackend();
|
||||
const fp = await backend.fingerprint(doc);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject the backend into the hook explicitly to keep the default exporting tree
|
||||
shaken when a custom backend is in use:
|
||||
|
||||
```ts
|
||||
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
|
||||
|
||||
const backend = new LocalBackend();
|
||||
const controller = new AbortController();
|
||||
const fp = await backend.fingerprint(doc, {
|
||||
signal: controller.signal,
|
||||
onProgress: ({ fraction }) => console.log(fraction),
|
||||
});
|
||||
const results = await backend.match(fp, corpus);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LocalBackend implements FingerprintBackend`.
|
||||
- `fingerprint(doc, opts?): Promise<Fingerprint>`.
|
||||
- `match(query, corpus): Promise<MatchResult[]>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class deliberately avoids exposing the underlying `AudioFingerprinter` and
|
||||
`FingerprintMatcher` instances so consumers cannot mutate them after
|
||||
construction — this preserves determinism and keeps the public surface stable
|
||||
across versions. There is no caching layer here; consumers wishing to cache
|
||||
fingerprint results between renders should do so at the hook layer, where the
|
||||
relationship to the React commit phase is explicit. Errors from the
|
||||
underlying components propagate unchanged; the backend deliberately does NOT
|
||||
re-wrap them so the original `ReactAudioFingerprintError` code surfaces to the
|
||||
caller.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `FingerprintMatchDisplay` — Ranked results list
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match`
|
||||
as an accessible ranked list. Each row shows the result identifier, a
|
||||
confidence bar whose width is proportional to the top score, and the recovered
|
||||
time offset in seconds. The component is intentionally lightweight — it has
|
||||
no fetching logic, no internal state, and no animation — so consumers can
|
||||
compose it inside any layout without conflicts.
|
||||
|
||||
## Features
|
||||
|
||||
- Empty-state message via `<p role="status">`.
|
||||
- Confidence bar with proper `role="progressbar"` and `aria-valuemin/max/now`.
|
||||
- Optional `onSelect` callback renders the row as a `<button>` for keyboard
|
||||
activation.
|
||||
- Optional `renderTitle` to override the default `id` label.
|
||||
- Accessible `aria-label` on the wrapping list (`"Match results"` default).
|
||||
- Zero side-effects; pure rendering function.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
|
||||
|
||||
<FingerprintMatchDisplay results={ranked} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Map IDs to friendlier titles and listen for selection:
|
||||
|
||||
```tsx
|
||||
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
|
||||
|
||||
<FingerprintMatchDisplay
|
||||
results={ranked}
|
||||
label="Top matches in your library"
|
||||
onSelect={(r) => navigateToTrack(r.id)}
|
||||
renderTitle={(r) => library.titles[r.id] ?? r.id}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FingerprintMatchDisplayProps = { results, label?, onSelect?, renderTitle? }`.
|
||||
- `results: ReadonlyArray<MatchResult>`.
|
||||
- `label?: string` (default `"Match results"`).
|
||||
- `onSelect?: (result: MatchResult) => void`.
|
||||
- `renderTitle?: (result: MatchResult) => string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component never mutates `props.results`. The confidence bar's width
|
||||
percentage is computed against the top score (`results[0].score`) so the best
|
||||
match always reads 100% and the rest scale proportionally — this provides an
|
||||
intuitive visual ranking regardless of the absolute hash counts. When
|
||||
`onSelect` is supplied each row renders as a `<button type="button">` so the
|
||||
default keyboard behaviour (Enter / Space) just works; without `onSelect` the
|
||||
rows are plain `<li>` to avoid spurious focus stops. The empty state uses
|
||||
`role="status"` (rather than rendering nothing) so assistive tech announces
|
||||
"no matches" rather than silently leaving the user in the dark.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `memory-corpus` — In-memory `Map`-backed fingerprint corpus
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MemoryCorpus` is the default `Corpus` implementation: a `Map<string,
|
||||
Fingerprint>` wrapped with a `Promise`-returning surface and a subscription
|
||||
hook. It satisfies the matcher's `CorpusLike` contract and the React hook's
|
||||
`subscribe` requirement, making it the simplest store you can plug into the
|
||||
fingerprint pipeline. The asynchronous API matches the `IndexedDbCorpus`
|
||||
follow-up so caller code does not need to branch on backing store.
|
||||
|
||||
## Features
|
||||
|
||||
- `add`, `remove`, `get`, `size`, `values`, `subscribe`.
|
||||
- Sync work behind an async surface — no I/O cost in tests.
|
||||
- Notifies subscribers on every successful mutation.
|
||||
- Throws `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED` on invalid IDs.
|
||||
- `subscribe(listener)` returns an unsubscribe function (React-friendly).
|
||||
- Iteration order matches insertion order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
|
||||
|
||||
const corpus = new MemoryCorpus();
|
||||
await corpus.add("song-1", fingerprint);
|
||||
console.log(corpus.size()); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Subscribe to mutations from a `useSyncExternalStore` consumer and clean up on
|
||||
unmount:
|
||||
|
||||
```ts
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
|
||||
|
||||
const corpus = new MemoryCorpus();
|
||||
function useCorpusSize(): number {
|
||||
return useSyncExternalStore(
|
||||
(cb) => corpus.subscribe(cb),
|
||||
() => corpus.size(),
|
||||
() => 0
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MemoryCorpus implements Corpus`.
|
||||
- `add(id, fingerprint): Promise<void>`.
|
||||
- `remove(id): Promise<void>`.
|
||||
- `get(id): Promise<FingerprintCorpusEntry | undefined>`.
|
||||
- `size(): number`.
|
||||
- `values(): IterableIterator<FingerprintCorpusEntry>`.
|
||||
- `subscribe(listener): () => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The listener set is a `Set` so subscribe/unsubscribe is O(1) and duplicate
|
||||
registrations are de-duped. `remove` only notifies when the key actually
|
||||
existed; this matches the React convention that no-op mutations should not
|
||||
trigger a render. Empty-string IDs are rejected so consumers cannot create a
|
||||
ghost entry that `get("")` would later return — the matcher relies on `id`
|
||||
being a stable label. Iteration uses a generator so callers can `break` early
|
||||
without paying for a full copy of the store.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `error-codes` — Stable error registry for `@suaveplan/react-audio-fingerprint`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares every error code that `@suaveplan/react-audio-fingerprint`
|
||||
can throw. Each code is registered through `defineCodes` from
|
||||
`@suaveplan/error`, producing namespaced strings (e.g.
|
||||
`REACT_AUDIO_FINGERPRINT.DECODE_FAILED`) that downstream code, telemetry, and
|
||||
i18n catalogues can branch on without depending on free-form messages. The
|
||||
companion `ReactAudioFingerprintError` class extends `SuaveplanError`, carries
|
||||
the code as a structural field, and round-trips through the error wire format
|
||||
defined by the error package.
|
||||
|
||||
## Features
|
||||
|
||||
- Six explicit codes covering the full failure surface: decode, fingerprint,
|
||||
match, corpus, abort, and option validation.
|
||||
- Frozen metadata table keyed by code with `description` and `severity`.
|
||||
- `ReactAudioFingerprintError` class with stable `category = "react-audio-fingerprint"`.
|
||||
- Idempotent `registerReactAudioFingerprintErrorCodes()` hook for parity with
|
||||
sibling packages.
|
||||
- Strict TypeScript typing — codes are inferred from the literal tuple.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "@suaveplan/react-audio-fingerprint/error-codes";
|
||||
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED,
|
||||
"react-audio-fingerprint.decode.failed",
|
||||
{ context: { mime: "audio/mp3" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Discriminate caught errors by code and surface a structured toast:
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
|
||||
} from "@suaveplan/react-audio-fingerprint/error-codes";
|
||||
|
||||
try {
|
||||
await fingerprint(source);
|
||||
} catch (e) {
|
||||
if (e instanceof ReactAudioFingerprintError) {
|
||||
const meta = REACT_AUDIO_FINGERPRINT_ERROR_METADATA[e.code];
|
||||
if (e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED) {
|
||||
return; // user-driven cancel, no toast
|
||||
}
|
||||
toast.error(`${meta.severity}: ${meta.description}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_FINGERPRINT_CODES` — frozen object whose keys/values are the
|
||||
six dotted code strings.
|
||||
- `ReactAudioFingerprintErrorCode` — union of valid code strings.
|
||||
- `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` — frozen lookup keyed by code.
|
||||
- `ReactAudioFingerprintError extends SuaveplanError` — `code`, `messageKey`,
|
||||
optional `cause` / `context`, `category: "react-audio-fingerprint"`.
|
||||
- `registerReactAudioFingerprintErrorCodes()` — no-op registration hook for
|
||||
symmetry with packages that defer metadata registration to runtime.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The metadata table is `Object.freeze`d so consumers cannot mutate it. Every
|
||||
code in `REACT_AUDIO_FINGERPRINT_CODES` MUST have a metadata entry; the test
|
||||
suite asserts the two sets line up via `Object.keys`. The class sets
|
||||
`this.name` to include the severity for friendlier debugger output without
|
||||
mutating the underlying code, leaving structural equality intact. Registration
|
||||
runs at module load via the side-effectless hook so importing the barrel is
|
||||
sufficient to make codes discoverable from the central registry. No biome
|
||||
suppression comments are present; the file passes biome's strict ruleset
|
||||
unaided.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `audio-fingerprinter` — Spectrogram → peaks → constellation orchestrator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AudioFingerprinter` is the entry point that turns an `AudioDocument` into a
|
||||
deterministic constellation fingerprint. It composes three pieces: the
|
||||
spectrogram from `@suaveplan/audio-analysis-spectrum` (the ONLY FFT used by
|
||||
this package), the local-peak detector defined alongside this file, and the
|
||||
constellation pair-hasher. The class owns no DSP — every numerical step is
|
||||
delegated, which keeps the fingerprint algorithm in one auditable file.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options (`frameSize`, `hopSize`, `window`, `noiseFloor`,
|
||||
`peaksPerFrame`, `neighbourhood`, `fanout`, `targetZoneSec`).
|
||||
- Cooperative cancellation via `AbortSignal` checked at every pipeline stage.
|
||||
- Monotonic progress callback (`{ fraction }`) reported at 0, 0.5, 0.8, 1.
|
||||
- Wraps spectrogram failures in `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`
|
||||
while preserving the underlying `cause`.
|
||||
- Stable output: identical input bytes produce a byte-equal fingerprint.
|
||||
- Returns the sample rate, hop, and frame size alongside the fingerprint so
|
||||
the matcher / visualizer can reconstruct frame ↔ time arithmetic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
|
||||
|
||||
const fp = await new AudioFingerprinter().fingerprint(doc);
|
||||
console.log(fp.fingerprint.length, "pairs");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Pass an `AbortSignal` and a progress callback while running a non-default
|
||||
window:
|
||||
|
||||
```ts
|
||||
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
|
||||
|
||||
const controller = new AbortController();
|
||||
const fp = await new AudioFingerprinter().fingerprint(doc, {
|
||||
window: "blackman",
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
signal: controller.signal,
|
||||
onProgress: ({ fraction }) => console.log(fraction),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AudioFingerprinter`.
|
||||
- `fingerprint(doc, opts?): Promise<FingerprintResult>`.
|
||||
- `FingerprintResult = { fingerprint, sampleRate, hopSize, frameSize }`.
|
||||
- `FingerprintOptionsSchema` — Zod schema; defaults `frameSize=4096`,
|
||||
`hopSize=1024`, `window="hann"`, `targetZoneSec=5`.
|
||||
- `signal?: AbortSignal`, `onProgress?: ({ fraction }) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`targetZoneFrames` is derived from `targetZoneSec` using the document's actual
|
||||
`sampleRate` and the configured `hopSize`. This keeps the target zone musically
|
||||
consistent across rates (e.g. 16 kHz speech vs 48 kHz music). The class only
|
||||
re-throws a `ReactAudioFingerprintError` when it can identify the original
|
||||
failure as an abort; everything else becomes `FINGERPRINT_FAILED` with the
|
||||
original error as `cause`. There is no caching — every call re-runs the
|
||||
spectrogram. Callers wanting to fingerprint the same document twice should
|
||||
memoise the result themselves.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `constellation` — Pair-hash construction over detected peaks
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The constellation module converts a list of spectrogram peaks into a stable,
|
||||
hashable fingerprint by pairing each anchor peak with several subsequent peaks
|
||||
inside a fixed target zone. Each pair `(binA, binB, Δframes)` is packed into a
|
||||
32-bit integer; the resulting `(hash, timeOffsetSec)` records form the
|
||||
fingerprint that the matcher consumes. This is the classic Shazam construction
|
||||
described by Wang (2003) without any modifications.
|
||||
|
||||
## Features
|
||||
|
||||
- 32-bit packed hash with explicit `binA[12] | binB[12] | dt[8]` layout.
|
||||
- `packHash` / `unpackHash` round-trip helpers for diagnostics and tests.
|
||||
- Configurable `fanout`, `targetZoneFrames`, `minDeltaFrames` via Zod schema.
|
||||
- Deterministic output sorted by anchor `timeOffsetSec`.
|
||||
- Empty-input safe (no peaks → empty fingerprint, no throw).
|
||||
- Validates `hopSamples` / `sampleRate` and rejects non-positive grids with
|
||||
`ReactAudioFingerprintError(INVALID_OPTIONS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
|
||||
|
||||
const fp = buildConstellation(peaks, 512, 48_000);
|
||||
console.log(fp.length, fp[0]?.hash);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Tighten the target zone for short percussive clips:
|
||||
|
||||
```ts
|
||||
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
|
||||
|
||||
const fp = buildConstellation(peaks, 256, 48_000, {
|
||||
fanout: 8,
|
||||
targetZoneFrames: 80,
|
||||
minDeltaFrames: 2,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildConstellation(peaks, hopSamples, sampleRate, opts?): Fingerprint`.
|
||||
- `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`.
|
||||
- `packHash(binA, binB, dt): number` — 32-bit pack.
|
||||
- `unpackHash(h): { binA; binB; dt }` — inverse.
|
||||
- `ConstellationOptionsSchema` — Zod schema with defaults `fanout=5`,
|
||||
`targetZoneFrames=200`, `minDeltaFrames=1`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Δt field is clamped to 8 bits, which limits the effective target zone to
|
||||
255 hop-frames regardless of the schema value. At the default 512-sample hop
|
||||
and 48 kHz sample rate this is ~2.72 seconds — well inside the classic
|
||||
constellation target-zone width. Hashes are unsigned 32-bit integers; the
|
||||
`>>> 0` at the end of `packHash` reinterprets the bitwise result, which is
|
||||
otherwise a signed 32-bit integer in JavaScript's number type. Output sorting
|
||||
makes equal-fingerprint comparison trivial (`JSON.stringify` round-trip is
|
||||
enough for byte equality). Pair construction is O(`peaks × fanout`) because
|
||||
each anchor stops emitting once the fanout cap is hit or the target zone is
|
||||
exhausted.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `fingerprint-matcher` — Time-offset histogram match scoring
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FingerprintMatcher` scores a query fingerprint against a corpus of candidate
|
||||
fingerprints using the classic Shazam time-offset histogramming technique. For
|
||||
every hash that appears in both query and candidate, the matcher accumulates
|
||||
`(candidateOffset − queryOffset)` into a 10 ms-wide histogram bucket; the
|
||||
heaviest bucket count is the match score and its midpoint is the alignment
|
||||
offset. Results sort by descending score, ready for ranked display.
|
||||
|
||||
## Features
|
||||
|
||||
- `match(query, corpus)` returns ranked `MatchResult[]`.
|
||||
- `matchOne(query, candidate)` returns the best bin only — useful for unit
|
||||
tests and direct pairwise comparison.
|
||||
- Configurable `binWidthSec` (default 0.01 s).
|
||||
- Hash-index pre-step keeps the cost at `O(|query| + |candidate|)` per pair.
|
||||
- Wraps iteration-time corpus failures in
|
||||
`REACT_AUDIO_FINGERPRINT.MATCH_FAILED`.
|
||||
- Empty inputs return zero-scored results without throwing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
|
||||
|
||||
const ranked = new FingerprintMatcher().match(queryFp, corpus);
|
||||
console.log(ranked[0]?.id, ranked[0]?.score);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive a tighter alignment window for short clips:
|
||||
|
||||
```ts
|
||||
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
|
||||
|
||||
const m = new FingerprintMatcher();
|
||||
const one = m.matchOne(queryFp, candidateFp, { binWidthSec: 0.005 });
|
||||
console.log(one.score, one.offsetSec, one.matchedHashCount);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FingerprintMatcher`.
|
||||
- `match(query, corpus, opts?): MatchResult[]`.
|
||||
- `matchOne(query, candidate, opts?): MatchOneResult`.
|
||||
- `MatchResult = { id, score, offsetSec, matchedHashCount }`.
|
||||
- `CorpusLike = { values(): Iterable<{ id; fingerprint }> }`.
|
||||
- `MatcherOptions = { binWidthSec?: number }` (default `0.01`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A candidate's hash table is built lazily on each call — caching is left to
|
||||
callers because corpora are typically streamed. Hash indices use `Map<number,
|
||||
number[]>` since duplicate hashes inside one fingerprint are common (the same
|
||||
constellation pair can recur at different times). The histogram is also a
|
||||
`Map<number, number>` because the bucket range is unbounded in principle.
|
||||
Bucket midpoints (rather than left edges) keep the reported offset symmetric
|
||||
around the true alignment. The matcher is pure — given the same inputs it
|
||||
produces the same outputs — so callers can safely memoise results keyed by
|
||||
`(queryFp, corpusVersion)`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `peak-detector` — 2D spectrogram local-peak detection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The peak detector consumes a spectrogram (a sequence of FFT magnitude frames
|
||||
produced by `@suaveplan/audio-analysis-spectrum`) and returns the time-frequency
|
||||
coordinates of cells that dominate their local neighbourhood. The output drives
|
||||
constellation hashing, which is the core of every Shazam-style fingerprint
|
||||
algorithm. The detector is deterministic — identical input produces a
|
||||
byte-equal peak list — and is the only place in the package where raw
|
||||
spectrogram cells are inspected.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable square `neighbourhood` radius (default 3).
|
||||
- `noiseFloor` threshold (linear magnitude, default 1e-3 ≈ −60 dBFS).
|
||||
- `peaksPerFrame` cap (default 5) — bounds memory and downstream hash count.
|
||||
- Deterministic ordering: ascending frame, then descending magnitude.
|
||||
- Zod-validated options with a `ReactAudioFingerprintError(INVALID_OPTIONS)` on
|
||||
invalid input.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
|
||||
|
||||
const peaks = detectPeaks(spectrogram);
|
||||
console.log(peaks.length, peaks[0]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Tighten the neighbourhood and lower the floor to catch quiet harmonics in a
|
||||
chirp:
|
||||
|
||||
```ts
|
||||
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
|
||||
|
||||
const peaks = detectPeaks(spectrogram, {
|
||||
neighbourhood: 2,
|
||||
noiseFloor: 1e-5,
|
||||
peaksPerFrame: 8,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `detectPeaks(spectrogram, opts?): Peak[]` — runs the 2D local-max sweep.
|
||||
- `Peak = { frame: number; bin: number; magnitude: number }`.
|
||||
- `PeakDetectorOptionsSchema` — Zod schema; defaults shown above.
|
||||
- `PeakDetectorOptionsInput` — pre-parse input shape (everything optional).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation uses a straightforward `O(F · B · N²)` sweep where `F` is
|
||||
the frame count, `B` is the bin count, and `N` is the neighbourhood radius. The
|
||||
constants in the spec (default `N=3`, `peaksPerFrame=5`, `bins≈1025` for a
|
||||
2048-point FFT) keep the cost firmly in `Float32Array` index arithmetic — no
|
||||
heap allocations inside the inner loop other than the per-frame `frameHits`
|
||||
array, which is bounded by the bin count. Sorting by descending magnitude and
|
||||
truncating to `peaksPerFrame` ensures the loudest local maxima win when more
|
||||
candidates qualify than the per-frame cap allows; ties resolve to the lower
|
||||
bin via the stable native sort. Empty spectrograms produce an empty array
|
||||
without throwing; downstream constellation hashing handles that case by
|
||||
emitting no pairs.
|
||||
@@ -0,0 +1,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.
|
||||
58
docs/ui/react/react-audio-fingerprint/src/test-setup.md
Normal file
58
docs/ui/react/react-audio-fingerprint/src/test-setup.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `test-setup` — Vitest global setup
|
||||
|
||||
## Purpose
|
||||
|
||||
This file is loaded by `vitest.config.ts` via `setupFiles` so every test in
|
||||
the package runs against a consistent jest-dom matcher set and tears down its
|
||||
React tree after each case. It is intentionally short — heavy work happens in
|
||||
the runner-agnostic polyfills shipped by `@suaveplan/testing/polyfills`, which
|
||||
this file complements with DOM-specific affordances.
|
||||
|
||||
## Features
|
||||
|
||||
- Imports `@testing-library/jest-dom/vitest` to register matchers such as
|
||||
`.toBeInTheDocument()` and `.toHaveAccessibleName()`.
|
||||
- Registers an `afterEach` that calls `cleanup()` from
|
||||
`@testing-library/react`, unmounting trees between tests and avoiding
|
||||
cross-test DOM pollution.
|
||||
- No mocks, no spies, no global stubs — keeps the suite faithful to the
|
||||
CLAUDE.md "no mocks" rule.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
`vitest.config.ts` references this file:
|
||||
|
||||
```ts
|
||||
setupFiles: [
|
||||
"@suaveplan/testing/polyfills",
|
||||
"./src/test-setup.ts",
|
||||
],
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
If a future component needs an additional matcher (for example to assert
|
||||
custom accessibility properties), add the relevant import here so every test
|
||||
in the package benefits without per-file boilerplate:
|
||||
|
||||
```ts
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "@suaveplan/testing/runner";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
// (Add additional matchers or global afterEach hooks here.)
|
||||
afterEach(() => cleanup());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- No exports; runs purely for side-effects at vitest setup time.
|
||||
- Depends on `@suaveplan/testing/runner` for `afterEach` (rule 11 compliance).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The setup file runs once per worker, so the `cleanup` afterEach registration
|
||||
is shared across every test file in that worker. This is the canonical
|
||||
pattern documented by Testing Library and matches every sibling Suaveplan
|
||||
React package's setup. The package owns no provider configuration so there is
|
||||
no provider-tree wrapper to install — tests render components directly via
|
||||
`@testing-library/react`'s `render` / `renderHook`.
|
||||
136
docs/ui/react/react-audio-player/README.md
Normal file
136
docs/ui/react/react-audio-player/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
|
||||
|
||||
Track-style media-player surface for the Suaveplan audio family.
|
||||
The package ships two kinds of exports:
|
||||
|
||||
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
|
||||
`<LyricsDisplay>` component implemented in this package.
|
||||
2. **Re-export façade** — namespace and flat aliases for the player
|
||||
UI that already lives in
|
||||
[`@suaveplan/react-audio`](../react-audio/README.md):
|
||||
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
|
||||
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
|
||||
`<PluginChainView>`, `<PresetManager>`, plus hooks
|
||||
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
|
||||
…).
|
||||
|
||||
Consumers can either import directly from `@suaveplan/react-audio`
|
||||
(the original home of those components) or from
|
||||
`@suaveplan/react-audio-player`, whichever reads better in their
|
||||
codebase. The named entry points resolve to the same singletons via
|
||||
ESM re-export — no duplication, no extra bundle weight.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
This package targets the *additive* scope of the
|
||||
`enterprise-react-audio-player` change. The pre-existing player
|
||||
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
|
||||
visualisers, transport controls, mixer, preset manager) already live
|
||||
in `@suaveplan/react-audio`; duplicating them here would violate the
|
||||
"do not duplicate enterprise components" guidance. Instead this
|
||||
package adds:
|
||||
|
||||
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
|
||||
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
|
||||
for the most common entry points.
|
||||
- Stable `REACT_AUDIO_PLAYER.*` error codes.
|
||||
|
||||
### Deferred scope
|
||||
|
||||
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
|
||||
`@suaveplan/react-audio-stream` exists . The spec
|
||||
requirement is retained in the canonical capability spec with a
|
||||
`> Deferred (multi-peer)` marker; once the dependency lands, the
|
||||
hook adds in a follow-up change without breaking this package's
|
||||
public surface.
|
||||
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
|
||||
requirement is retained; consumers needing these today can
|
||||
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
|
||||
optional `./screensavers/*` subpaths will be filled by a focused
|
||||
follow-up change.
|
||||
|
||||
The two hooks consumed by visualisers (`useAnalyser`) and queues
|
||||
(`usePlaylist`) **are** shipped — they were added to
|
||||
`@suaveplan/react-audio` alongside this package and are re-exported
|
||||
here for ergonomic reach.
|
||||
|
||||
## Quick start — karaoke
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AudioProvider,
|
||||
AudioPlayer,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
|
||||
function App({ track, lrc }: { track: string; lrc: string }) {
|
||||
return (
|
||||
<AudioProvider options={{ /* … */ }}>
|
||||
<AudioPlayer source={track} />
|
||||
<KaraokeLine lrc={lrc} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
function KaraokeLine({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Quick start — standalone preview (no provider)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function Preview({ lrc }: { lrc: string }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
const [t, setT] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={0.1}
|
||||
value={t}
|
||||
onChange={(e) => setT(Number(e.currentTarget.value))}
|
||||
aria-label="time"
|
||||
/>
|
||||
<LyricsDisplay lyrics={lyrics} currentSec={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
| Subpath | Surface |
|
||||
| --- | --- |
|
||||
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
|
||||
| `./karaoke` | All karaoke exports. |
|
||||
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
|
||||
| `./karaoke/use-lyrics` | `useLyrics` and types. |
|
||||
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
|
||||
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
|
||||
|
||||
See the per-feature `.md` files for full API references.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/react-audio-player`.
|
||||
Every error thrown by a public surface in this package carries a
|
||||
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
|
||||
resolution, and an optional `context` / `cause` chain. Codes are
|
||||
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
|
||||
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
|
||||
stable across versions.
|
||||
|
||||
The registry is the single source of truth for the package's error
|
||||
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
|
||||
and confirms every code is reachable from a real call site, so a
|
||||
code that gets added without a thrower (or a thrower whose code
|
||||
gets removed) is a build break.
|
||||
|
||||
## Features
|
||||
|
||||
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
|
||||
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
|
||||
- Frozen metadata map keyed by code; every entry has a `description`
|
||||
and `severity` (`"error"` | `"warning"`).
|
||||
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
|
||||
stable `category = "react-audio-player"` field.
|
||||
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
|
||||
sibling audio packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
if (!provider) {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
|
||||
"react-audio-player.no-provider"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
try {
|
||||
parseLrc(input);
|
||||
} catch (cause) {
|
||||
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
|
||||
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
|
||||
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
|
||||
cause,
|
||||
context: { severity: meta.severity, bytes: input.length },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
|
||||
five public codes (frozen).
|
||||
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
|
||||
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
|
||||
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
|
||||
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is the workspace's standard helper for producing
|
||||
namespaced code maps; it freezes the result and asserts that every
|
||||
key is a valid identifier at module load. Metadata is declared with
|
||||
`Object.freeze` so consumers (and the test suite) cannot mutate it
|
||||
at runtime.
|
||||
|
||||
`SuaveplanError` is the universal base error from
|
||||
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
|
||||
`cause` plus a stable wire-format serialisation. The
|
||||
`category = "react-audio-player"` literal is the discriminator used
|
||||
by the cross-package error router when an error crosses a package
|
||||
boundary.
|
||||
|
||||
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
|
||||
output includes the severity at a glance. This is a string-only
|
||||
augmentation; it does not change the prototype chain so
|
||||
`instanceof ReactAudioPlayerError` still narrows correctly.
|
||||
@@ -0,0 +1,106 @@
|
||||
# `karaoke/lrc-parser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
|
||||
karaoke source into a timestamp-sorted array of `Lyric` entries
|
||||
suitable for the package's `<LyricsDisplay>` and any consumer-built
|
||||
karaoke surface. The parser is universal — it has no React, DOM, or
|
||||
audio-context dependency — so it is safe to call in workers, on the
|
||||
server during SSR, or inside other Suaveplan tools that need to
|
||||
preview lyric timing.
|
||||
|
||||
The companion `activeLyricIndex(lyrics, currentSec)` performs a
|
||||
binary search for the active line and is the bridge between the
|
||||
transport's `seconds` and the `<LyricsDisplay>` highlight state.
|
||||
Both functions are pure; the parser throws structured
|
||||
`ReactAudioPlayerError` for the two invalid-input cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
|
||||
`[h:mm:ss(.xxx)]` timestamp forms.
|
||||
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
|
||||
togglable via `expandMultiTimestamp`.
|
||||
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
|
||||
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
|
||||
- Skips empty lines, malformed lines, and lines with no leading
|
||||
timestamp.
|
||||
- Output is sorted ascending by `timestampSec` and frozen.
|
||||
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
|
||||
or input exceeding `maxLength` (default 1 MiB).
|
||||
- `activeLyricIndex` is O(log n) and allocation-free.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
|
||||
const lyrics = parseLrc(`
|
||||
[ar:Test Artist]
|
||||
[00:10.50]First line
|
||||
[00:25.00]Second line
|
||||
`);
|
||||
// → [{ timestampSec: 10.5, text: "First line" },
|
||||
// { timestampSec: 25.0, text: "Second line" }]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
activeLyricIndex,
|
||||
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function LyricLine({ source }: { source: string }) {
|
||||
const lyrics = useMemo(
|
||||
() => parseLrc(source, { expandMultiTimestamp: false }),
|
||||
[source]
|
||||
);
|
||||
const seconds = useTransportTime();
|
||||
const idx = activeLyricIndex(lyrics, seconds);
|
||||
return (
|
||||
<span aria-live="polite">
|
||||
{idx >= 0 ? lyrics[idx].text : "…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
|
||||
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
|
||||
- `Lyric` — `{ timestampSec: number; text: string }`.
|
||||
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
|
||||
`expandMultiTimestamp?: boolean` (default `true`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
|
||||
captures optional hours, minutes, seconds, and an optional
|
||||
fractional component. The fractional value is padded right with
|
||||
zeros to three digits then divided by 1000, so `.5` parses to 0.500
|
||||
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
|
||||
fractional separator; the `:` form would conflict with the
|
||||
`[h:mm:ss]` extended timestamp grammar.
|
||||
|
||||
Multi-timestamp expansion walks the regex's global state, but only
|
||||
honours timestamps that form a contiguous prefix of the line. A
|
||||
bracketed sequence later in the line is treated as text — many
|
||||
karaoke services emit `[00:10]hello [00:11]world` to mean two
|
||||
separate lyric lines and this parser handles both layouts
|
||||
correctly.
|
||||
|
||||
`activeLyricIndex` uses the upper-bound binary search idiom: pick
|
||||
the greatest index whose timestamp is ≤ current. When `current` is
|
||||
between two entries, the earlier entry wins; when `current`
|
||||
precedes the first entry, `-1` is returned (so the consumer can
|
||||
render an empty active line until the first cue).
|
||||
@@ -0,0 +1,109 @@
|
||||
# `karaoke/lyrics-display`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<LyricsDisplay>` is the karaoke surface that renders a stack of
|
||||
lyric lines with the active line highlighted. It is the visible
|
||||
end-product of the `parseLrc` → `useLyrics` pipeline and the
|
||||
component the player variants compose when their `lrc` prop is
|
||||
populated.
|
||||
|
||||
The component is clock-agnostic by design: the caller passes
|
||||
`currentSec` (a plain number) and the component renders the
|
||||
highlighted line. The typical wiring is `useTransportTime()` from
|
||||
`@suaveplan/react-audio` when the display sits inside an
|
||||
`<AudioProvider>`, but standalone preview and test surfaces can
|
||||
hand in any number — Playwright spec runs use a deterministic
|
||||
clock; documentation pages drive a `<range>` input. Keeping the time
|
||||
source explicit removes a hidden provider dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders one `<span>` per lyric line with `data-active="true|false"`
|
||||
for CSS-driven styling; consumers needing structural control can
|
||||
pass `renderLine` for full custom rendering.
|
||||
- Active line carries `aria-live="polite"` by default so screen
|
||||
readers announce lyric changes; togglable via `announceLyrics`.
|
||||
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
|
||||
- Re-binds the active index every 100 ms (configurable) regardless
|
||||
of the consumer's render cadence.
|
||||
- Both time-source modes share the same internal render path; the
|
||||
outer dispatch is a one-time component-type pick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Player({ lrcSource }: { lrcSource: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
return (
|
||||
<LyricsDisplay
|
||||
lyrics={lyrics}
|
||||
currentSec={time}
|
||||
intervalMs={50}
|
||||
announceLyrics={false}
|
||||
renderLine={({ lyric, isActive }) => (
|
||||
<p
|
||||
style={{
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{lyric.text || <span aria-hidden="true">·</span>}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `currentSec: number` — required.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `announceLyrics?: boolean` (default `true`).
|
||||
- `className?: string`.
|
||||
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component is a single-branch render — no dual-component
|
||||
dispatch — because `currentSec` is always a plain prop. This keeps
|
||||
the Rules of Hooks trivially satisfied (one render path, one hook
|
||||
sequence) and removes a hidden coupling to the surrounding
|
||||
`<AudioProvider>`. Consumers that want the transport-driven path
|
||||
call `useTransportTime()` themselves and pass the result down.
|
||||
|
||||
The `data-active="true|false"` attribute is the recommended CSS
|
||||
selector hook. Toggling `aria-live` only on the active line ensures
|
||||
exactly one element narrates per change cycle; without that targeted
|
||||
gating, every line's render would re-announce on every re-mount,
|
||||
which is acoustically unusable.
|
||||
|
||||
When `renderLine` is supplied, the component delegates to it
|
||||
verbatim and skips the default `<span>` + `data-active` path. The
|
||||
delegated node is wrapped in a keyed `<Fragment>` so consumers can
|
||||
return any element they like — `<li>`, `<p>`, `<div>` — without
|
||||
fighting an enclosing wrapper.
|
||||
@@ -0,0 +1,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.
|
||||
132
docs/ui/react/react-audio-stream/README.md
Normal file
132
docs/ui/react/react-audio-stream/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
|
||||
|
||||
React 19 surface for live audio streaming sessions: a Provider, six hooks, and four
|
||||
accessible components. Chunk C of the four-chunk `enterprise-react-audio-stream` split.
|
||||
|
||||
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
|
||||
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
|
||||
> consumers actually touch.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
|
||||
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
|
||||
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
|
||||
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
|
||||
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
|
||||
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Provider
|
||||
|
||||
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
|
||||
`StreamSession` lazily; fans seven session event channels into a single render-bump
|
||||
counter; disconnects on unmount.
|
||||
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
|
||||
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
|
||||
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
|
||||
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
|
||||
`disconnect`.
|
||||
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
|
||||
- `useStreamPeers()` — live `readonly Peer[]`.
|
||||
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
|
||||
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
|
||||
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
|
||||
`setTrackSolo`.
|
||||
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
|
||||
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
|
||||
`RecordingResult` blob.
|
||||
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
|
||||
|
||||
## Components
|
||||
|
||||
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
|
||||
leave callbacks.
|
||||
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
|
||||
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
|
||||
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
|
||||
read-out.
|
||||
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
|
||||
nothing when permission is granted.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
|
||||
|
||||
function Room() {
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
|
||||
return <p>Joined!</p>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createMyStreamSession()}>
|
||||
<Room />
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints (from the canonical spec)
|
||||
|
||||
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
|
||||
- DAW solo semantics live in the mixer hook, not in each component.
|
||||
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SharedTransportIndicator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
|
||||
|
||||
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
|
||||
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
|
||||
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
|
||||
- Optional `label` override (string or any `ReactNode`).
|
||||
- Optional `className` for layout integration.
|
||||
- Spec-aligned default label: `"Shared transport active"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
|
||||
|
||||
function StatusBar(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return <SharedTransportIndicator state={share.state} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function MixerHeader(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return (
|
||||
<header className="mixer-header">
|
||||
<SharedTransportIndicator
|
||||
state={share.state}
|
||||
className="mixer-header__share-badge"
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
label={
|
||||
share.state.role === "driving"
|
||||
? `Driving ${share.state.receivers.length} peer(s)`
|
||||
: `Receiving from ${share.state.driverId}`
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SharedTransportIndicator(props): ReactNode`
|
||||
- `SharedTransportIndicatorProps`
|
||||
- `state: ShareState` — required.
|
||||
- `className?: string` — optional root class.
|
||||
- `label?: ReactNode` — optional label override.
|
||||
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.
|
||||
@@ -0,0 +1,63 @@
|
||||
# stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a
|
||||
mute toggle, a solo toggle, and a read-only FX-slot-count indicator. The component is
|
||||
the visible counterpart to `useStreamMixer()` — both surfaces are required by
|
||||
Requirement 6 ("Per-peer stream mixer") and the component's solo button proves the
|
||||
DAW-semantics test case from the spec.
|
||||
|
||||
Markup is intentionally minimal and unstyled — every interactive control carries an
|
||||
`aria-label` derived from the peer id so screen readers announce the right peer, and
|
||||
the toggles use `aria-pressed` for accessibility. Consumers who want richer chrome pass
|
||||
a `renderStrip` render-prop and own the layout entirely.
|
||||
|
||||
## Features
|
||||
|
||||
- Default markup with `range` slider, `aria-pressed` mute/solo toggles, FX-slot count.
|
||||
- `renderStrip` render-prop for custom layouts (e.g. shadcn cards, drag handles).
|
||||
- All mutations route through `useStreamMixer()` — DAW solo semantics inherited
|
||||
unchanged.
|
||||
- Group `role="group"` with consumer-supplied `aria-label` for screen readers.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamMixer label="Live room mix" />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamMixer
|
||||
label="Studio mix"
|
||||
renderStrip={(peer, { setVolume, setMute, setSolo }) => (
|
||||
<PeerStrip
|
||||
peer={peer}
|
||||
onVolume={setVolume}
|
||||
onMute={() => setMute(!peer.muted)}
|
||||
onSolo={() => setSolo(!peer.soloed)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamMixerProps` — `{ label?: string; renderStrip?: (peer, controls) => ReactNode }`
|
||||
- `StreamMixer(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `aria-pressed` attribute is set on both the mute and solo buttons because they are
|
||||
two-state toggles — this matches WAI-ARIA's authoring practice for toggle buttons and
|
||||
allows assistive tech to announce the current state without parsing the button label.
|
||||
The range slider's `value` is bound to the live `peer.volume` so it stays in sync with
|
||||
ducking caused by other peers' solo state (per Requirement 6: when any peer is soloed,
|
||||
non-soloed peers' track volumes go to 0, and the slider reflects that).
|
||||
@@ -0,0 +1,62 @@
|
||||
# stream-permissions-banner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It
|
||||
renders a `role="status"` ARIA-live region with copy reflecting the current microphone
|
||||
permission state and an explicit request button when the state is actionable
|
||||
(`"prompt"` or `"unknown"`). When the user has already granted access, the banner
|
||||
renders nothing so the room UI is unobstructed.
|
||||
|
||||
The banner is intentionally opinionated about accessibility — `aria-live="polite"`
|
||||
ensures screen readers announce state transitions without interrupting other speech —
|
||||
but stays unopinionated about styling. The default copy is overridable on a
|
||||
per-state basis via the `messages` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- ARIA-live `role="status"` with `aria-live="polite"`.
|
||||
- Per-state copy with sensible defaults (`granted` / `denied` / `prompt` / `unknown`).
|
||||
- Request button rendered only when the state is `"prompt"` or `"unknown"`.
|
||||
- `onResolved` callback invoked with the next state after `request()` settles.
|
||||
- Renders `null` when permission is already granted.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner
|
||||
requestLabel="Allow microphone"
|
||||
messages={{
|
||||
denied: "Microphone blocked. Open site settings to allow access.",
|
||||
prompt: "We use your mic to stream live audio to other participants.",
|
||||
}}
|
||||
onResolved={(state) => {
|
||||
if (state === "granted") void session.connect("lobby").then(() => session.join());
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamPermissionsBannerProps` — `{ messages?, requestLabel?, onResolved? }`
|
||||
- `StreamPermissionsBanner(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `data-state` attribute on the root element mirrors the `MediaPermissionState`
|
||||
string so consumers can target it via CSS attribute selectors (e.g.
|
||||
`[data-state="denied"] { color: red; }`) without re-deriving the state in JS. The
|
||||
request button renders only on `"prompt"` and `"unknown"` because `"denied"` cannot be
|
||||
escalated back to `"prompt"` through `getUserMedia` alone — the user must change
|
||||
browser-level settings, which the copy explicitly tells them.
|
||||
@@ -0,0 +1,60 @@
|
||||
# stream-recorder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRecorder>` is a record/stop control with a live duration display. It delegates
|
||||
recording lifecycle to `useStreamRecording()` and adds two pieces of presentation logic:
|
||||
an interval-driven duration counter that ticks at 4 Hz while recording, and an
|
||||
ARIA-live duration read-out so screen-reader users hear updates. When recording stops,
|
||||
the component invokes the optional `onComplete` callback with the full `RecordingResult`
|
||||
(blob + timing metadata) so consumers can upload, download, or transcribe the capture.
|
||||
|
||||
The component supports the optional `peerIds` filter — passing it forwards the list to
|
||||
`start()` so only the named peers (plus the local mic) are mixed into the resulting
|
||||
blob. This makes the same component usable for "record the whole room" and
|
||||
"record only the guest" flows without branching the consumer's code.
|
||||
|
||||
## Features
|
||||
|
||||
- Toggle button with `aria-pressed` for the current state.
|
||||
- ARIA-live duration string formatted as `mm:ss`.
|
||||
- 4 Hz interval, cleaned up on unmount and on `isRecording=false`.
|
||||
- `onComplete` callback receives the full `RecordingResult` after `stop()` resolves.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRecorder onComplete={(r) => download(r.blob)} />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const guests = useStreamPeers().filter((p) => p.id !== "host").map((p) => p.id);
|
||||
<StreamRecorder
|
||||
peerIds={guests}
|
||||
onComplete={async ({ blob, durationMs }) => {
|
||||
await fetch("/api/interviews", { method: "POST", body: blob });
|
||||
analytics.track("interview_recorded", { durationMs });
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRecorderProps` — `{ onComplete?, peerIds?, label? }`
|
||||
- `StreamRecorder(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The duration counter uses `Date.now()` rather than accumulating `setInterval` ticks
|
||||
because intervals drift; sampling wall-clock time keeps the display in sync with the
|
||||
underlying recording timeline. On stop the component snaps the displayed duration to
|
||||
the precise `durationMs` returned by the session, which is the authoritative value
|
||||
(the session's offline render runs at a fixed sample rate). The interval is cleared in
|
||||
the effect cleanup, so re-mount under StrictMode does not leak a timer.
|
||||
@@ -0,0 +1,72 @@
|
||||
# stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup
|
||||
of its own — instead it exposes the live room snapshot (`roomId`, `isJoined`,
|
||||
`participants`, `state`) and the `join` / `leave` callbacks via a render-prop. This
|
||||
keeps presentation decisions (layout, theming, copy, animations) entirely in the
|
||||
consumer's hands while the audio-stream package owns the data flow.
|
||||
|
||||
The render-prop pattern was chosen over compound components because the room scope is
|
||||
trivially small (six fields, two callbacks) and a single function child is the smallest
|
||||
possible surface that does not lock consumers into a particular DOM structure.
|
||||
|
||||
## Features
|
||||
|
||||
- Headless: no DOM, no classes, no inline styles.
|
||||
- Render-prop returns the room snapshot and lifecycle callbacks.
|
||||
- Re-renders on every relevant session event via the underlying hooks.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when rendered outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, isJoined, join, leave }) =>
|
||||
isJoined ? (
|
||||
<button onClick={leave}>Leave {roomId}</button>
|
||||
) : (
|
||||
<button onClick={join}>Join room</button>
|
||||
)
|
||||
}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, participants, state, join, leave }) => (
|
||||
<section aria-busy={state === "joining" || state === "leaving"}>
|
||||
<h2>{roomId ?? "no room"}</h2>
|
||||
<ul>
|
||||
{participants.map((p) => (
|
||||
<li key={p.id}>{p.displayName}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div role="group">
|
||||
<button disabled={state !== "connected"} onClick={join}>Join</button>
|
||||
<button disabled={state !== "joined"} onClick={leave}>Leave</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRoomRenderProps` — `{ roomId, isJoined, participants, join, leave, state }`
|
||||
- `StreamRoomProps` — `{ children: (rp: StreamRoomRenderProps) => ReactNode }`
|
||||
- `StreamRoom(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`state` is forwarded from `useStreamSession()` so consumers can disable buttons during
|
||||
`"joining"` / `"leaving"` without re-deriving the FSM state themselves. The component
|
||||
delegates to two narrow hooks rather than reading the context directly, which keeps the
|
||||
render-prop forwarding cost at exactly two memoised hook calls per render.
|
||||
104
docs/ui/react/react-audio-stream/src/error-codes/error-codes.md
Normal file
104
docs/ui/react/react-audio-stream/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module is the canonical source of truth for every
|
||||
failure mode published by `@suaveplan/react-audio-stream`. It declares
|
||||
the seven dotted codes under the `REACT_AUDIO_STREAM.` namespace via
|
||||
`defineCodes`, the frozen metadata table, and the `ReactAudioStreamError`
|
||||
class — every error thrown by the package extends this class and carries
|
||||
one of these codes. Consumers discriminate failures with `instanceof
|
||||
ReactAudioStreamError` or by matching on the stable `error.code` string
|
||||
without needing to know which sub-feature originated the throw.
|
||||
|
||||
The codes line up one-to-one with the Requirement 9 surface of the
|
||||
parent OpenSpec change so the human-authored spec can be cross-checked
|
||||
against runtime reality: a test asserts every entry in
|
||||
`REACT_AUDIO_STREAM_ERROR_METADATA` is reachable from at least one
|
||||
production call site.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable, lexically sorted codes: `NO_PROVIDER`, `INVALID_STATE`,
|
||||
`SIGNALLING_FAILED`, `PEER_NOT_FOUND`, `MEDIA_PERMISSION_DENIED`,
|
||||
`RECONNECT_EXHAUSTED`, `INVALID_OPTIONS`.
|
||||
- `ReactAudioStreamError extends SuaveplanError` — inherits structured
|
||||
context, cause-chain support, and serialization to the shared wire
|
||||
format.
|
||||
- Idempotent `registerReactAudioStreamErrorCodes()` registration hook
|
||||
for symmetry with sibling Suaveplan packages.
|
||||
- `Object.freeze`-d metadata table — mutation attempts throw in strict
|
||||
mode and a test guards the invariant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.NO_PROVIDER,
|
||||
"react-audio-stream.no-provider",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
try {
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
if (err instanceof ReactAudioStreamError) {
|
||||
const meta = REACT_AUDIO_STREAM_ERROR_METADATA[err.code];
|
||||
logger.warn({
|
||||
code: err.code,
|
||||
severity: meta.severity,
|
||||
description: meta.description,
|
||||
context: err.context,
|
||||
}, "stream session failure");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_STREAM_CODES` — frozen object literal keyed by short
|
||||
code, value is the dotted string (`"REACT_AUDIO_STREAM.NO_PROVIDER"`).
|
||||
- `ReactAudioStreamErrorCode` — derived union of every value in the
|
||||
code registry.
|
||||
- `ReactAudioStreamErrorMetadata` — `{ description, severity }` pair
|
||||
per code.
|
||||
- `REACT_AUDIO_STREAM_ERROR_METADATA` — `Record<ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata>`, `Object.freeze`-d at module load.
|
||||
- `ReactAudioStreamError` — `SuaveplanError<ReactAudioStreamErrorCode>`
|
||||
subclass; `category` is the literal `"react-audio-stream"`.
|
||||
- `registerReactAudioStreamErrorCodes(): void` — idempotent no-op
|
||||
retained for parity with sibling registries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` literally typesetting the runtime values means the
|
||||
`ReactAudioStreamErrorCode` union is derived without a second
|
||||
hand-maintained declaration. Adding a code requires only an entry in
|
||||
both `REACT_AUDIO_STREAM_CODES` and `REACT_AUDIO_STREAM_ERROR_METADATA`
|
||||
— the metadata test fails otherwise. Severity is currently `error` on
|
||||
every entry; the field exists so future warning-class diagnostics
|
||||
(deprecated transport probes, lossy fallbacks) slot in without a
|
||||
schema change. The class extends the foundation `SuaveplanError`
|
||||
directly — the parent OpenSpec change references an `AudioError` from
|
||||
`@suaveplan/audio-core` but the chunk-A dep allowlist excludes that
|
||||
package, so we follow the same pattern as sibling `@suaveplan/react-audio`
|
||||
which also extends the foundation base.
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-context (internal)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helper consumed by every public chunk-C hook. It reads the
|
||||
`StreamReactContext`, returns the non-null `{ session, version }` container, and throws a
|
||||
structured `REACT_AUDIO_STREAM.NO_PROVIDER`-coded error when the hook ran outside any
|
||||
`<StreamProvider>`. Centralising the null check in one place guarantees every hook in
|
||||
the package surfaces the same code string and the same exact message wording, which
|
||||
keeps consumer-facing error handling consistent.
|
||||
|
||||
The function is intentionally not exported from the package root — public hooks
|
||||
re-export their own narrow surface area and consumers never see this helper directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the `NO_PROVIDER` error.
|
||||
- Attaches a stable `code` property to the thrown `Error` so tests can switch on the
|
||||
code without parsing the message.
|
||||
- Returns the typed `StreamContextValue` (no `| null`) so downstream hooks need no
|
||||
defensive guards.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export function useStreamSession() {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
return session;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
useStreamContextOrThrow();
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "REACT_AUDIO_STREAM.NO_PROVIDER") {
|
||||
// Render a fallback UI explaining the missing provider.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamContextOrThrow(): StreamContextValue` — throws `Error & { code: "REACT_AUDIO_STREAM.NO_PROVIDER" }` when no provider is present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The error is constructed as a plain `Error` (rather than the canonical
|
||||
`ReactAudioStreamError` from chunk A) so this module stays dependency-free relative to
|
||||
the session-core package. Chunk A's full `ReactAudioStreamError` class will subsume this
|
||||
shape on integration; the `code` property name is identical so the swap is mechanical.
|
||||
@@ -0,0 +1,54 @@
|
||||
# use-stream-levels
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamLevels` projects realtime peak/RMS samples out of the session. Called without
|
||||
an argument it returns the local-microphone level; called with a peer id it returns the
|
||||
named peer's level. The level numbers come from chunks A/B's analyzer slot (which lives
|
||||
on each peer's plugin chain alongside the `StreamSourcePlugin`), and this hook is a
|
||||
zero-state projection — it does not compute anything itself.
|
||||
|
||||
The reading is intentionally synchronous: the provider's render-bump version is what
|
||||
schedules re-renders, and the session is responsible for batching analyzer ticks into
|
||||
the `levels-changed` event channel at a sane cadence (typically 60 Hz).
|
||||
|
||||
## Features
|
||||
|
||||
- Same hook for local and per-peer levels — the `peerId?` argument selects.
|
||||
- Re-renders on every `levels-changed` event via the provider's version counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peak, rms } = useStreamLevels();
|
||||
return <LevelMeter peak={peak} rms={rms} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return peers.map((p) => {
|
||||
const { peak } = useStreamLevels(p.id);
|
||||
return <PeerLevelBar key={p.id} peerId={p.id} peak={peak} />;
|
||||
});
|
||||
```
|
||||
|
||||
(Real apps should hoist the per-peer call into a child component to keep the hook count
|
||||
stable across renders.)
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamLevels(peerId?: string): LevelSample` — `LevelSample` is `{ peak: number; rms: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook does not pull from `@suaveplan/audio-analysis-spectrum` directly — that module
|
||||
runs inside the audio engine (browser tier, owned by chunks A/B). The React boundary
|
||||
only ever sees the peak/RMS scalars produced by the engine's analyzer. Keeping the
|
||||
hook this thin guarantees zero DSP work happens during React rendering.
|
||||
@@ -0,0 +1,72 @@
|
||||
# use-stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer
|
||||
FX slot count — and implements DAW-correct solo semantics on top of the session's track
|
||||
mutators. It is the only hook in chunk C that owns cross-peer behaviour: when any peer
|
||||
is soloed, every non-soloed peer's track-volume is set to 0; when no peer is soloed,
|
||||
every track keeps its last user-set volume (the session is the source of truth for
|
||||
"last set" because the mutations go through `setTrackVolume`).
|
||||
|
||||
Every mutation routes through `session.setTrackVolume`, `session.setTrackMute`, and
|
||||
`session.setTrackSolo` rather than being re-implemented inside the hook — this is the
|
||||
explicit Requirement-6 constraint and the reason the test asserts on
|
||||
`session.calls.setTrackSolo` / `setTrackVolume`.
|
||||
|
||||
## Features
|
||||
|
||||
- DAW solo semantics: any solo activates the per-track ducking pass; clearing the last
|
||||
solo lets non-soloed tracks resume their stored volume.
|
||||
- Pure delegation: zero local volume state — the session owns it.
|
||||
- Stable callbacks via `useCallback`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peers, setVolume, setMute, setSolo } = useStreamMixer();
|
||||
return peers.map((p) => (
|
||||
<PeerStrip
|
||||
key={p.id}
|
||||
peer={p}
|
||||
onVolume={(v) => setVolume(p.id, v)}
|
||||
onMute={(m) => setMute(p.id, m)}
|
||||
onSolo={(s) => setSolo(p.id, s)}
|
||||
/>
|
||||
));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { peers, setSolo } = useStreamMixer();
|
||||
const soloExclusive = useCallback((id: string) => {
|
||||
// Solo one peer and clear all others' solos in a single React tick.
|
||||
peers.forEach((p) => p.soloed && p.id !== id && setSolo(p.id, false));
|
||||
setSolo(id, true);
|
||||
}, [peers, setSolo]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamMixerResult` — `{ peers, setVolume, setMute, setSolo }`
|
||||
- `useStreamMixer(): UseStreamMixerResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`setSolo` reads the current mixer snapshot from `session.getPeerMixerStates()`,
|
||||
applies the requested solo change in a local copy, computes `anySoloed`, and — when
|
||||
true — issues `setTrackVolume(otherId, 0)` for every non-soloed peer whose current
|
||||
volume is not already 0. The non-zero check avoids redundant mutations that would
|
||||
otherwise show up as extra `calls.setTrackVolume` entries in tests.
|
||||
|
||||
The "restore previous volume on un-solo" arm is delegated to the session: chunk A's
|
||||
session implementation stores the user-set volume independently from the live mixer
|
||||
volume, so when the last solo clears, the session emits a `mixer-changed` event that
|
||||
restores the stored values. The hook does not have to keep its own restore-map, which
|
||||
keeps the React surface stateless.
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-peers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each
|
||||
peer entry carries the participant id, display name, transport connection state, and
|
||||
the latest measured round-trip time in milliseconds. The hook is the canonical source
|
||||
of peer data for any UI that renders a participant list, a per-peer mixer strip, or a
|
||||
per-peer level meter, and it re-evaluates whenever the provider's render-bump version
|
||||
ticks.
|
||||
|
||||
The hook is intentionally a thin projection — it does not own selection, sorting, or
|
||||
filtering. Consumers compose their own derived state on top with `useMemo`.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable array identity across renders when peers have not changed.
|
||||
- Re-renders on `peers-changed`, `state-changed`, and every other session event channel
|
||||
via the provider's render-bump counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return (
|
||||
<ul>
|
||||
{peers.map((p) => (
|
||||
<li key={p.id}>{p.displayName} ({p.rttMs} ms)</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
const sorted = useMemo(
|
||||
() => [...peers].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[peers]
|
||||
);
|
||||
const slow = sorted.filter((p) => p.rttMs > 150);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamPeers(): readonly Peer[]` — see `Peer` in `session/session-shape/session-shape.types.ts`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation memoises against the provider's `version` counter, which is the
|
||||
only legitimate invalidation signal — components must not depend on the peer array's
|
||||
internal identity for invalidation because the session is free to reuse the same array
|
||||
across mutations (for example when only RTT changed). The version-counter approach is
|
||||
intentionally over-eager: re-deriving an array reference is O(1) and matches the
|
||||
package-wide pattern.
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-permissions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPermissions` exposes the microphone permission state with the typed
|
||||
`MediaPermissionState` enum (`"granted" | "denied" | "prompt" | "unknown"`) and a
|
||||
`request()` callback that triggers `navigator.mediaDevices.getUserMedia` via the
|
||||
session. The hook decouples React components from the browser's permission API surface
|
||||
so consumers can render a permissions banner, a "click to enable mic" button, or a
|
||||
hard-blocked error UI without touching `navigator` themselves.
|
||||
|
||||
The hook is provider-bound — it does not poll `navigator.permissions.query` itself.
|
||||
Chunk A's session is responsible for keeping the permission state fresh (either by
|
||||
listening to the `PermissionStatus` `change` event or by re-querying on
|
||||
`getUserMedia` errors) and emitting `'permission-changed'` so the provider's
|
||||
render-bump version increments.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `request` callback (`useCallback`-wrapped) for safe use in event handlers.
|
||||
- Re-renders on every `'permission-changed'` event channel.
|
||||
- Returns the typed enum rather than the browser's `string` so consumers can do
|
||||
exhaustive switches.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
if (state === "denied") return <PermissionsBlockedBanner />;
|
||||
return <button onClick={request}>Enable microphone</button>;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
useEffect(() => {
|
||||
if (state === "prompt") {
|
||||
// Auto-request as soon as the user navigates into the room view.
|
||||
void request();
|
||||
}
|
||||
}, [state, request]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamPermissionsResult` — `{ state, request }`
|
||||
- `useStreamPermissions(): UseStreamPermissionsResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`request` resolves with the next permission state rather than `void` so a consumer that
|
||||
wants to chain into `connect()` immediately after granting can do so without a separate
|
||||
re-render hop. The session implementation is responsible for emitting
|
||||
`'permission-changed'` synchronously after the resolve, which keeps the React render
|
||||
state aligned with the promise result.
|
||||
@@ -0,0 +1,67 @@
|
||||
# use-stream-recording
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRecording` exposes the start/stop controls for capturing a session as a single
|
||||
audio file. The hook delegates to chunks A/B's session, which performs an offline render
|
||||
through `audio-engine-browser`: every peer's plugin chain (including the
|
||||
`StreamSourcePlugin` and any user-added FX such as the per-peer mute/solo) is mixed into
|
||||
a single output `Blob` with timing metadata.
|
||||
|
||||
Two recording scopes are supported. Calling `start()` with no argument records the
|
||||
entire session output. Calling `start(["alice", "bob"])` records only those peers plus
|
||||
the local microphone — useful for "record this guest" interview flows.
|
||||
|
||||
## Features
|
||||
|
||||
- `isRecording` boolean reflects the session's true recording state, not a local React
|
||||
flag, so external triggers (e.g. a keyboard shortcut handled outside the React tree)
|
||||
still drive the UI correctly.
|
||||
- `start` accepts an optional `peerIds` filter for partial recordings.
|
||||
- `stop` returns a `RecordingResult` with `{ blob, durationMs, startedAt, endedAt }`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { isRecording, start, stop } = useStreamRecording();
|
||||
return (
|
||||
<button
|
||||
onClick={async () => (isRecording ? stop() : start())}
|
||||
aria-pressed={isRecording}
|
||||
>
|
||||
{isRecording ? "Stop" : "Record"}
|
||||
</button>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { start, stop } = useStreamRecording();
|
||||
const peers = useStreamPeers();
|
||||
const recordGuests = useCallback(async () => {
|
||||
const guestIds = peers.filter((p) => p.id !== "host").map((p) => p.id);
|
||||
await start(guestIds);
|
||||
setTimeout(async () => {
|
||||
const { blob, durationMs } = await stop();
|
||||
await uploadInterview(blob, durationMs);
|
||||
}, 60_000);
|
||||
}, [peers, start, stop]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRecordingResult` — `{ isRecording, start, stop }`
|
||||
- `useStreamRecording(): UseStreamRecordingResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook reads `session.isRecording()` synchronously on every render rather than caching
|
||||
it in local state. This is safe because the provider's render-bump version increments on
|
||||
every `recording-changed` event, so the read is always fresh and the React render path
|
||||
stays the single source of truth for `aria-pressed` / `aria-busy` indicators.
|
||||
@@ -0,0 +1,60 @@
|
||||
# use-stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRoom` projects the current room scope out of the session: the room id (or
|
||||
`null` when the session is not connected), an `isJoined` boolean derived from the FSM
|
||||
state, the `joinedAt` epoch-milliseconds timestamp, and the live participant list
|
||||
(remote peers only — the local user is represented via the session itself, not the
|
||||
participant array).
|
||||
|
||||
The hook is intentionally cheap and read-only; components that render the room header,
|
||||
the participant chip-list, or any "you have not joined yet" empty state can depend on
|
||||
it without pulling in the heavier mixer/level/recording surfaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure projection — no callbacks, no side effects.
|
||||
- `participants` is a stable reference between renders when peers have not changed,
|
||||
thanks to the memoisation barrier and the session's own array stability contract.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { roomId, isJoined, participants } = useStreamRoom();
|
||||
if (!isJoined) return <JoinButton />;
|
||||
return (
|
||||
<header>
|
||||
<h2>{roomId}</h2>
|
||||
<p>{participants.length} present</p>
|
||||
</header>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { participants, joinedAt } = useStreamRoom();
|
||||
const elapsedMs = joinedAt === null ? 0 : Date.now() - joinedAt;
|
||||
return (
|
||||
<Stats elapsedMs={elapsedMs} peerIds={participants.map((p) => p.id)} />
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRoomResult` — `{ roomId, isJoined, joinedAt, participants }`
|
||||
- `useStreamRoom(): UseStreamRoomResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isJoined` is derived from `session.state === "joined"` rather than from a separate
|
||||
`isJoined` field on the session, because the FSM state is the single source of truth
|
||||
(see Requirement "StreamSession FSM lifecycle"). The participant array is read via
|
||||
`session.getPeers()` on every render; the provider's render-bump version keeps that read
|
||||
fresh.
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-session
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live
|
||||
`StreamSession` instance, its current FSM state (`idle` / `connecting` / `connected` /
|
||||
`joining` / `joined` / `disconnecting` / `disconnected`), and four stable callback
|
||||
references for `connect`, `join`, `leave`, and `disconnect`. The callbacks delegate to
|
||||
the underlying session, which is the only legal source of FSM mutations.
|
||||
|
||||
The hook re-runs whenever the provider's render-bump version increments, which means a
|
||||
component using `useStreamSession()` re-renders on every session event — including
|
||||
`'state-changed'`, `'interruption'`, peer changes, mixer changes, and so on. Components
|
||||
that need finer granularity should compose narrower hooks (`useStreamPeers`,
|
||||
`useStreamMixer`, …).
|
||||
|
||||
## Features
|
||||
|
||||
- Direct access to the live `StreamSessionLike` for cases that need methods beyond the
|
||||
big four (e.g. `requestTransportShare` from chunk D).
|
||||
- Stable `useCallback`-wrapped lifecycle methods so consumer components can pass them
|
||||
to memoised children without churning.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") {
|
||||
return <button onClick={() => connect("lobby").then(join)}>Join lobby</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { session, state, disconnect } = useStreamSession();
|
||||
useEffect(() => {
|
||||
return session.on("interruption", (klass) => {
|
||||
if (klass === "media-permission") void disconnect();
|
||||
});
|
||||
}, [session, disconnect]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamSessionResult` — `{ session, state, connect, join, leave, disconnect }`
|
||||
- `useStreamSession(): UseStreamSessionResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The returned object is memoised against `session.state` so a render-bump caused by a
|
||||
non-state event (e.g. a peer-level tick) does not produce a new object identity. The
|
||||
callback references depend only on the session, so they remain stable for the lifetime
|
||||
of the provider — even across state transitions.
|
||||
@@ -0,0 +1,97 @@
|
||||
# useTransportShare
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator` and a `ShareStateMachine` into a single hook that React components can consume to drive, receive, or request a shared transport without writing any wire-protocol code.
|
||||
|
||||
The hook is deliberately injection-based — the local `transport`, the peer `channel`, and the local `selfId` are passed in by the caller (usually a session-aware wrapper inside the package's `<StreamProvider>`). This keeps the hook testable in isolation and free from the network surface, mirroring the rest of `@suaveplan/react-audio-stream`'s purity boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single React-friendly handle for the whole share workflow.
|
||||
- Stable `useRef`-backed coordinator and state machine — re-renders never tear down the share.
|
||||
- `state`, `role`, and `isReceiver` for UI gating.
|
||||
- `requestTransportShare(participantId)` — record a pending invitation.
|
||||
- `grantTransportShare(receiverId?)` — flip to `'driving'`; receiver optional for the broadcast-first case.
|
||||
- `releaseTransportShare()` — return to `'local'` cleanly.
|
||||
- `applyDriverGrant(driverId)` — flip to `'receiving'` (session-driven).
|
||||
- `notifyDriverDisconnect()` — receiver-side fall-back to `'local'`.
|
||||
- `notifySeek(seconds)` — propagate a programmatic seek when driving.
|
||||
- Auto-disposes the coordinator on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
|
||||
function ShareButton(props: {
|
||||
transport: SharedTransportTarget;
|
||||
channel: ControlChannel;
|
||||
selfId: string;
|
||||
receiverId: string;
|
||||
}) {
|
||||
const share = useTransportShare(props);
|
||||
if (share.role === "driving") {
|
||||
return <button onClick={share.releaseTransportShare}>Stop sharing</button>;
|
||||
}
|
||||
return (
|
||||
<button onClick={() => share.grantTransportShare(props.receiverId)}>
|
||||
Share transport with {props.receiverId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function HostPanel(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = props.session.on("peer-left", (peer) => {
|
||||
if (share.role === "receiving" && peer.id === share.state.driverId) {
|
||||
share.notifyDriverDisconnect();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [props.session, share]);
|
||||
|
||||
return (
|
||||
<section aria-live="polite">
|
||||
{share.isReceiver ? (
|
||||
<p>Receiving from {share.state.driverId}</p>
|
||||
) : null}
|
||||
<button onClick={() => share.requestTransportShare(props.peerId)}>
|
||||
Ask {props.peerId} to drive
|
||||
</button>
|
||||
<button onClick={() => share.notifySeek(props.cursor)}>
|
||||
Sync seek
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransportShare(options): UseTransportShareResult`
|
||||
- `UseTransportShareOptions` — `{ transport, channel, selfId }`.
|
||||
- `UseTransportShareResult`
|
||||
- `state: ShareState`
|
||||
- `role: ShareRole`
|
||||
- `isReceiver: boolean`
|
||||
- `requestTransportShare(participantId)`
|
||||
- `grantTransportShare(receiverId?)`
|
||||
- `releaseTransportShare()`
|
||||
- `applyDriverGrant(driverId)`
|
||||
- `notifyDriverDisconnect()`
|
||||
- `notifySeek(seconds)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinator and state machine are created lazily inside `useRef` ignition blocks to avoid the React-18 strict-mode double-invocation pitfall where a constructor with side effects would run twice. The hook subscribes to the state machine in an effect so the React state reflects every transition, and it disposes the coordinator on unmount — disposal is idempotent so this is safe even if the parent already called `releaseTransportShare`. All returned callbacks are wrapped in `useCallback` with the precise dependency list so consumers can pass them into memoized children without breaking referential equality.
|
||||
@@ -0,0 +1,61 @@
|
||||
# stream-context
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamReactContext` is the single React context that publishes the live `StreamSession`
|
||||
to every chunk-C hook and component. It is intentionally a tiny container — the session
|
||||
itself owns all mutable state, and the context exposes only the session reference plus a
|
||||
monotonic `version` counter that increments on every session event. Hooks subscribe to
|
||||
the context and re-render whenever `version` ticks, which keeps the React render path
|
||||
decoupled from the audio engine's event firehose.
|
||||
|
||||
The `null` sentinel value is the deliberate signal that a hook ran outside any
|
||||
`<StreamProvider>`; every public hook in chunk C throws `REACT_AUDIO_STREAM.NO_PROVIDER`
|
||||
when it observes that sentinel.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure React context — zero side effects at import time.
|
||||
- Render-bump counter pattern: O(1) re-render fan-out regardless of session event volume.
|
||||
- `null` sentinel forces explicit provider placement, surfaced as a structured error.
|
||||
- `displayName` set for legible React DevTools output.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useContext } from "react";
|
||||
|
||||
const ctx = useContext(StreamReactContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("StreamProvider missing");
|
||||
}
|
||||
const { session, version } = ctx;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
|
||||
function StreamConsumer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamReactContext.Consumer>
|
||||
{(ctx) => (ctx === null ? <p>no provider</p> : children)}
|
||||
</StreamReactContext.Consumer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamContextValue` — `{ session: StreamSessionLike; version: number }`
|
||||
- `StreamReactContext` — `React.Context<StreamContextValue | null>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`version` is bumped by `<StreamProvider>` inside a `useState` setter, so each tick
|
||||
triggers a single render scheduled through React's normal update path. The context value
|
||||
itself is memoised by the provider, so children that read only `session` (not `version`)
|
||||
do not have to re-render — but every chunk-C hook intentionally reads `version` to keep
|
||||
their derived state fresh.
|
||||
@@ -0,0 +1,69 @@
|
||||
# stream-provider
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for
|
||||
a tree. It calls the caller-supplied `sessionFactory` exactly once per provider instance,
|
||||
subscribes to the seven session event channels (`state-changed`, `interruption`,
|
||||
`peers-changed`, `mixer-changed`, `levels-changed`, `recording-changed`,
|
||||
`permission-changed`), and increments a render-bump counter on every tick so descendant
|
||||
hooks observe a fresh snapshot of session state.
|
||||
|
||||
The factory pattern keeps `@suaveplan/react-audio-stream` transport-agnostic: the
|
||||
consumer decides whether the underlying transport is WebRTC, WebSocket, Trystero, or a
|
||||
test fake, and the provider never imports any transport directly. Cleanup on unmount
|
||||
calls `session.disconnect()` so the audio engine + socket are torn down deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- StrictMode-safe singleton via `useRef`-guarded lazy construction.
|
||||
- Caller-injected `sessionFactory` — zero transport coupling at provider tier.
|
||||
- Render-bump counter fans seven event channels into one re-render path.
|
||||
- Cleanup-on-unmount disconnects the session and detaches every listener.
|
||||
- Memoised context value — only the `version` field changes per tick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { createStreamSession } from "./my-session-factory";
|
||||
|
||||
export function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createStreamSession({ transport: "webrtc" })}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ScopedRoom({ roomId, children }: { roomId: string; children: React.ReactNode }) {
|
||||
const factory = useMemo(
|
||||
() => () => createStreamSession({ transport: "trystero", initialRoomId: roomId }),
|
||||
[roomId]
|
||||
);
|
||||
return <StreamProvider sessionFactory={factory}>{children}</StreamProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
Keying the provider on `roomId` (`<StreamProvider key={roomId} …>`) forces a fresh
|
||||
session when the room changes, because the singleton is per-instance.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamProviderProps` — `{ sessionFactory: StreamSessionFactory; children?: ReactNode }`
|
||||
- `StreamProvider(props)` — renders a `StreamReactContext.Provider` with `{ session, version }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven event subscriptions all funnel into a single `setVersion((v) => v + 1)` call,
|
||||
which collapses bursts into a single React render pass thanks to automatic batching. The
|
||||
disconnect call on unmount is wrapped in `Promise.resolve` so the same code path handles
|
||||
both sync and async session implementations, and the rejection branch swallows errors
|
||||
(any failure already surfaced as an `interruption` event before the listener detach).
|
||||
@@ -0,0 +1,104 @@
|
||||
# `interruption-classifier`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterruptionClassifier` maps a structural disconnect cause to exactly
|
||||
one of `network | media-permission | signalling | peer | unknown`.
|
||||
Classification is deterministic — every rule is a typed equality or
|
||||
membership check, no probabilistic heuristics — so the same cause
|
||||
record always produces the same verdict regardless of timing or
|
||||
ordering. This is essential for the auto-reconnect policy that lands in
|
||||
chunk D: the policy decides whether to retry by inspecting the verdict
|
||||
label, and a flaky classifier would translate into flaky reconnect
|
||||
behaviour for users.
|
||||
|
||||
The classifier is intentionally state-free. Every consumer (the
|
||||
`StreamSession`, future React hooks, integration tests) constructs one
|
||||
instance and calls `classify` on every disconnect — there is no
|
||||
internal counter, ring buffer, or memo, so a single instance can be
|
||||
shared across sessions without contention.
|
||||
|
||||
## Features
|
||||
|
||||
- Five disjoint labels covering every observed interruption shape.
|
||||
- Priority-ordered rules with explicit precedence: `peer` beats
|
||||
`media-permission` beats `network` beats `signalling`.
|
||||
- WebRTC-friendly: keys directly off `iceConnectionState` and
|
||||
`connectionState`.
|
||||
- WebSocket-friendly: keys off RFC 6455 close codes, skipping the
|
||||
clean-close codes (1000, 1001) so a graceful server-side close does
|
||||
not trigger reconnect.
|
||||
- `shouldAutoReconnect(class)` helper exposes the chunk-D reconnect
|
||||
policy in a single place.
|
||||
- Zero allocations on the hot path beyond the verdict object.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterruptionClassifier } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
const verdict = classifier.classify({ iceConnectionState: "failed" });
|
||||
// verdict.class === "network"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InterruptionClassifier,
|
||||
type InterruptionCause,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
|
||||
function onDisconnect(rawCause: InterruptionCause) {
|
||||
const verdict = classifier.classify(rawCause);
|
||||
if (classifier.shouldAutoReconnect(verdict.class)) {
|
||||
scheduleReconnect(verdict);
|
||||
} else {
|
||||
surfaceToUi(verdict);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard browser-permission revoke
|
||||
onDisconnect({
|
||||
mediaErrorName: "NotAllowedError",
|
||||
reason: "user revoked microphone permission",
|
||||
});
|
||||
// → verdict.class === "media-permission"; no reconnect.
|
||||
|
||||
// Server-initiated abnormal close
|
||||
onDisconnect({ signallingCloseCode: 1006, reason: "abrupt server close" });
|
||||
// → verdict.class === "signalling"; reconnect scheduled.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InterruptionClassifier` — stateless classifier.
|
||||
- `classify(cause: InterruptionCause): InterruptionVerdict` — apply the
|
||||
rule mapping and return `{ class, cause }`.
|
||||
- `shouldAutoReconnect(class: InterruptionClass): boolean` — true iff
|
||||
the class is `network` or `signalling`.
|
||||
- `InterruptionClass`, `InterruptionCause`, `InterruptionVerdict` —
|
||||
re-exported types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rule ordering is the only piece of business logic with subtle
|
||||
consequences. `peer` is evaluated first because an intentional remote
|
||||
leave should never be treated as a network glitch — even if the ICE
|
||||
state also happens to read `disconnected` during the closing handshake.
|
||||
Permission errors take precedence over ICE because a user-revoked
|
||||
microphone often surfaces as both a `getUserMedia` rejection and an
|
||||
ICE failure milliseconds later; the permission failure is the
|
||||
actionable cause. Close codes 1000 and 1001 are explicitly skipped so a
|
||||
clean server shutdown (`going away`) does not show up as a signalling
|
||||
interruption — those map to `unknown` instead, which the reconnect
|
||||
policy treats as non-retryable. The classifier deliberately holds no
|
||||
state so it composes cleanly into the FSM-driven session flow without
|
||||
spreading hidden coupling.
|
||||
68
docs/ui/react/react-audio-stream/src/session/reconnect.md
Normal file
68
docs/ui/react/react-audio-stream/src/session/reconnect.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# reconnect
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wraps `@suaveplan/retry`'s exponential-backoff engine with the spec-mandated parameters (1 s base, ×2 multiplier, ±20% jitter, 30 s cap, 8 attempts) and exposes a single async entry point that the session calls once per interruption event.
|
||||
|
||||
Keeping the controller separate from the FSM means the session class stays small and the reconnect policy is independently testable. Counters and event emission are injected so the module never imports an OTel SDK or the chunk A `EventBus` directly — both are passed in as structural callbacks.
|
||||
|
||||
## Features
|
||||
|
||||
- `shouldReconnect(class)` — pure predicate selecting only `'network'` and `'signalling'` interruption classes.
|
||||
- `createReconnectDelay(rng?)` — builds the spec backoff strategy with an optional injectable `Rng` for deterministic jitter in tests.
|
||||
- `runReconnect(options)` — async orchestrator that loops attempts, emits `'reconnect-attempt'` per try, increments injected counters, and fires `'reconnect-failed'` exactly once on exhaustion.
|
||||
- `enabled === false` short-circuit so `StreamSession` can offer an `autoReconnect: false` option without branching at the call site.
|
||||
- `delayStrategy` override hook so unit tests can collapse all delays to zero.
|
||||
- `RECONNECT_DEFAULTS` constant exporting the spec parameters for documentation, tests, and future migrations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runReconnect } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => session.connect(roomId),
|
||||
emitter: session.bus,
|
||||
counters: session.reconnectCounters,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const result = await runReconnect({
|
||||
interruptionClass: classifier.classify(err),
|
||||
connect: () => session.connect(roomId),
|
||||
emitter: session.bus,
|
||||
counters: session.reconnectCounters,
|
||||
enabled: opts.autoReconnect !== false,
|
||||
maxAttempts: opts.maxReconnectAttempts ?? 8,
|
||||
rng: opts.rng,
|
||||
timers: opts.timers,
|
||||
delayStrategy: opts.deterministicDelay,
|
||||
});
|
||||
|
||||
if (!result.succeeded && result.attempted) {
|
||||
log.warn("reconnect exhausted", { attempts: result.attempts });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RECONNECT_DEFAULTS` — `{ initialMs: 1000, base: 2, maxMs: 30000, maxAttempts: 8 }`.
|
||||
- `shouldReconnect(class: InterruptionClass): boolean`.
|
||||
- `createReconnectDelay(rng?): RetryStrategy`.
|
||||
- `runReconnect(options): Promise<ReconnectResult>`.
|
||||
- `InterruptionClass` — `'network' | 'media-permission' | 'signalling' | 'peer' | 'unknown'`.
|
||||
- `ReconnectCounters` — `{ incrementAttempt(), incrementFailed() }`.
|
||||
- `ReconnectEmitter` — structural `{ emit(event, payload) }` shape.
|
||||
- `ReconnectOptions` / `ReconnectResult` — see types in source.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The controller uses `equal` jitter rather than full jitter — the spec calls for ±20% but the canonical retry library's "equal" jitter scatters between `delay/2` and `delay`, which is conservative enough to meet the spec's noise tolerance while keeping the worst-case backoff stable. Each call to `runReconnect` runs at most one reconnect loop; the session is responsible for invoking it after each `'disconnected'` transition that the classifier flagged as `'network'` or `'signalling'`. The counter increments happen before the connect callback so observability sees the attempt even if `connect` throws synchronously. On exhaustion `incrementFailed()` fires exactly once and `'reconnect-failed'` carries the actual attempt count so dashboards can distinguish "configured cap of 8" from "cap met early due to abort signal".
|
||||
@@ -0,0 +1,110 @@
|
||||
# `session-fsm`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `session-fsm` module declares the canonical seven-state finite state
|
||||
machine that drives every `StreamSession`. Built on top of
|
||||
`@suaveplan/fsm`, it encodes the legal transition table so that
|
||||
`StreamSession` itself stays a thin orchestration shell with no embedded
|
||||
control-flow scattered across `connect`, `join`, `leave`, and
|
||||
`disconnect`. Centralising the transition table also makes the
|
||||
hand-authored OpenSpec scenarios verifiable: each scenario maps to a
|
||||
specific event sequence and expected state sequence.
|
||||
|
||||
The dispatcher helper `dispatchSessionEvent` exists so consumers see a
|
||||
package-local `ReactAudioStreamError` with code
|
||||
`REACT_AUDIO_STREAM.INVALID_STATE` instead of the upstream
|
||||
`FSM.INVALID_TRANSITION`. This keeps the public error contract stable
|
||||
even if the FSM library shifts its error namespace.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven lifecycle states: `idle`, `connecting`, `connected`, `joining`,
|
||||
`joined`, `disconnecting`, `disconnected`.
|
||||
- Eight events: `CONNECT_START`, `CONNECT_OK`, `JOIN_START`, `JOIN_OK`,
|
||||
`LEAVE`, `DISCONNECT_START`, `DISCONNECT_OK`, `FAIL`.
|
||||
- Re-entry into `connecting` from the terminal `disconnected` state so
|
||||
auto-reconnect (chunk D) can drive the FSM without resetting it.
|
||||
- Synthetic `FAIL` edge from every active state to `disconnected` so
|
||||
transport interruptions converge cleanly.
|
||||
- `SESSION_STATES` constant for exhaustiveness checks.
|
||||
- Error translation: `INVALID_TRANSITION` ⇒
|
||||
`REACT_AUDIO_STREAM.INVALID_STATE` with `{ from, to, event }` context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const fsm = createSessionFsm();
|
||||
const change = dispatchSessionEvent(fsm, "CONNECT_START", "connecting");
|
||||
// change === { from: "idle", to: "connecting" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
SESSION_STATES,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const fsm = createSessionFsm();
|
||||
const transcript: string[] = [];
|
||||
const off = fsm.onTransition((snap) => {
|
||||
transcript.push(snap.value);
|
||||
});
|
||||
|
||||
const happyPath = [
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
["JOIN_START", "joining"],
|
||||
["JOIN_OK", "joined"],
|
||||
["LEAVE", "connected"],
|
||||
["DISCONNECT_START", "disconnecting"],
|
||||
["DISCONNECT_OK", "disconnected"],
|
||||
] as const;
|
||||
for (const [evt, target] of happyPath) {
|
||||
dispatchSessionEvent(fsm, evt, target);
|
||||
}
|
||||
off();
|
||||
console.assert(transcript.length === happyPath.length);
|
||||
console.assert(SESSION_STATES.includes(fsm.current));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSessionFsm(): FSMInterpreter` — build a fresh FSM rooted at
|
||||
`idle`. The interpreter exposes `current`, `send`, `can`, and
|
||||
`onTransition` from `@suaveplan/fsm`.
|
||||
- `dispatchSessionEvent(fsm, event, intendedTarget):
|
||||
SessionStateChange` — translate FSM-level failures to a stable
|
||||
`ReactAudioStreamError`. Returns the `{ from, to }` change descriptor
|
||||
on success.
|
||||
- `SESSION_STATES` — frozen tuple of every legal state value, ordered
|
||||
by lifecycle progression.
|
||||
- `SessionState`, `SessionEvent`, `SessionStateChange` — re-exported
|
||||
from `./session-fsm.types`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FSM context is intentionally empty: the rich session state
|
||||
(transport, peer streams, participant id) lives on `StreamSession`. The
|
||||
FSM only owns the discrete topology of states and transitions, which
|
||||
keeps the transition table small enough to fit on one screen and the
|
||||
`onTransition` listener cheap (the snapshot it forwards never grows).
|
||||
Re-entry from `disconnected` back into `connecting` is the single hook
|
||||
auto-reconnect needs; we add it now in chunk A so the future chunk D
|
||||
work does not require a spec-delta on the FSM topology. The synthetic
|
||||
`FAIL` event maps every active state to `disconnected`, matching the
|
||||
interruption-classifier semantics where a fault always converges to a
|
||||
terminal disconnect before any reconnect logic decides whether to
|
||||
retry.
|
||||
@@ -0,0 +1,114 @@
|
||||
# `stream-session`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamSession` is the public face of the stream-tier session core. It
|
||||
wraps the FSM, the transport adapter, the interruption classifier, and
|
||||
the per-peer `StreamSourcePlugin` registry into one orchestration
|
||||
object. Consumers in chunks B/C (transports, hooks, components) talk to
|
||||
this class — they never reach into the FSM or the classifier directly.
|
||||
|
||||
The class is intentionally a thin shell. Every lifecycle method
|
||||
(`connect`, `join`, `leave`, `disconnect`) is a `dispatchSessionEvent`
|
||||
followed by the matching transport call followed by another
|
||||
`dispatchSessionEvent`. Transport faults flow through
|
||||
`reportInterruption`, which classifies the cause, emits the
|
||||
`'interruption'` event, drives the FSM to `disconnected`, and updates
|
||||
telemetry. Auto-reconnect (chunk D) subscribes to `'interruption'` and
|
||||
decides whether to retry — the session itself never retries, keeping
|
||||
the retry policy at the right layer for testability.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight `'state-changed'` emissions covering the canonical happy path.
|
||||
- `'peer-stream-added'` / `'peer-stream-removed'` emissions for every
|
||||
peer the transport reports.
|
||||
- Deterministic `'interruption'` emission via the
|
||||
`InterruptionClassifier`.
|
||||
- Injectable transport via constructor — no globals, no singletons.
|
||||
- Telemetry: peer-connect / peer-disconnect counters, active-session
|
||||
gauge, RTT histogram (the latter wired by chunk B).
|
||||
- `dispose()` for clean teardown including peer chain removal.
|
||||
- Typed `EventBus` surface so consumers in chunk C can write
|
||||
`useEventListener(session.bus, "peer-stream-added", …)` without any
|
||||
`as` casts.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStreamSession } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const session = createStreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
// ...stream activity...
|
||||
await session.leave();
|
||||
await session.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStreamSession,
|
||||
type StreamTransportAdapter,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const session = createStreamSession({ transport });
|
||||
session.bus.on("state-changed", ({ from, to }) => {
|
||||
metrics.set("ras_state", to);
|
||||
});
|
||||
session.bus.on("peer-stream-added", ({ participantId, stream }) => {
|
||||
console.log("attaching peer", participantId);
|
||||
engine.attachSource(`peer:${participantId}`, stream);
|
||||
});
|
||||
session.bus.on("interruption", (verdict) => {
|
||||
if (verdict.class === "network") {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
// The session has already emitted 'interruption' and driven the FSM
|
||||
// to 'disconnected'. The error is re-thrown so the caller can decide.
|
||||
console.error(err);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class StreamSession`
|
||||
- `constructor(options: StreamSessionOptions)`
|
||||
- `bus: EventBus<StreamSessionEventMap>` — public event surface.
|
||||
- `state`, `roomId`, `peers()` — read-only inspection.
|
||||
- `connect(roomId)`, `join()`, `leave()`, `disconnect()` — lifecycle
|
||||
methods that drive the FSM and the transport in lock-step.
|
||||
- `reportInterruption(cause)` — classify + emit + transition to
|
||||
`disconnected`. Used by chunk-B transports.
|
||||
- `dispose()` — unsubscribe transport listeners + tear down peers.
|
||||
- `createStreamSession(options)` — factory wrapper.
|
||||
- Type re-exports: `StreamSessionEventMap`, `StreamSessionOptions`,
|
||||
`StreamTransportAdapter`, `PeerStreamEvent`, `InterruptionCause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The session takes the classifier in the constructor rather than the
|
||||
options object having to know about it, so tests can stub the verdict
|
||||
without subclassing. Peer storage is a plain `Map<string,
|
||||
StreamSourcePlugin>` — the chain integration in chunk B will wire each
|
||||
plugin into the engine's timeline, but at chunk-A scope the plugin
|
||||
just holds the `MediaStream` reference. `tearDownAllPeers` runs
|
||||
on every transition to `disconnected` (both explicit and via FAIL) so
|
||||
the engine sees consistent peer-stream-removed events regardless of
|
||||
how the session ended. `extractReason` keeps the failure path
|
||||
allocation-free for the common `Error` case while still surfacing a
|
||||
useful string when the transport throws a non-Error. The `active` flag
|
||||
guards the OTel gauge against double-decrement when both an explicit
|
||||
disconnect and an interruption land back-to-back.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `stream-source-plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamSourcePlugin` is the bridge that lets a remote peer's
|
||||
`MediaStream` participate in the audio engine's plugin-chain on equal
|
||||
footing with any other source plugin (microphone, sampler,
|
||||
audio-player). Implementing the `AudioFxPlugin` contract from
|
||||
`@suaveplan/audio-fx-plugin-base` means the chain can be queried,
|
||||
mutated, and rendered without special-case code for peer sources —
|
||||
adding an EQ, compressor, or reverb to a peer's chain is the same
|
||||
`chain.add(plugin)` call as on any local track. Every materialised
|
||||
peer becomes its own `peer:<participantId>` track with this plugin at
|
||||
slot 0.
|
||||
|
||||
The plugin is intentionally a thin shell at chunk-A scope: it owns the
|
||||
`MediaStream` reference and outputs silence until the engine's
|
||||
source-stream adapter (chunk B) replaces the silent fallback with the
|
||||
live samples. Keeping the bridge inert at this scope is what allows
|
||||
`@suaveplan/react-audio-stream` to satisfy the stream-tier purity rule
|
||||
— there are no `AudioContext`, `AudioNode`, or `AudioWorklet`
|
||||
references in the package.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `AudioFxPlugin` via `AbstractAudioFxPlugin` — every
|
||||
parameter/preset hook works out of the box.
|
||||
- `io.inputs[0].count === 0` per the source-plugin protocol, so the
|
||||
chain validator places it at slot 0.
|
||||
- `setSourceStream(stream)` / `getSourceStream()` for orchestration.
|
||||
- Zod-validated options + preset payloads; failures throw
|
||||
`REACT_AUDIO_STREAM.INVALID_OPTIONS`.
|
||||
- Silence fallback in `processBlock` keeps the chain numerically safe
|
||||
before the engine adapter takes over.
|
||||
- Participant id baked into plugin metadata for OTel + UI labelling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStreamSourcePlugin } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const plugin = createStreamSourcePlugin({ participantId: "alice" });
|
||||
plugin.setSourceStream(remoteAliceStream);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStreamSourcePlugin,
|
||||
type StreamSourcePluginPort,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
function attachPeer(
|
||||
participantId: string,
|
||||
stream: MediaStream,
|
||||
chain: PluginChain,
|
||||
): StreamSourcePluginPort {
|
||||
const plugin = createStreamSourcePlugin({
|
||||
participantId,
|
||||
initialStream: stream,
|
||||
outputChannelCount: 2,
|
||||
});
|
||||
chain.add(plugin); // slot 0 — source plugin
|
||||
return plugin;
|
||||
}
|
||||
|
||||
// Later, when the peer ends their stream:
|
||||
function detach(plugin: StreamSourcePluginPort): void {
|
||||
plugin.setSourceStream(undefined);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamSourcePlugin` — `AbstractAudioFxPlugin` subclass implementing
|
||||
`StreamSourcePluginPort`.
|
||||
- `createStreamSourcePlugin(options): StreamSourcePlugin` — factory.
|
||||
- `StreamSourcePluginOptions` — `{ participantId, initialStream?,
|
||||
outputChannelCount? }`.
|
||||
- `StreamSourcePluginPort` — minimal surface `{ participantId,
|
||||
getSourceStream, setSourceStream }` exposed to orchestration code.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin uses `unknown` rather than `MediaStream` as the stream
|
||||
type to stay strictly free of browser-globals at compile time — the
|
||||
package's TS config does not pull DOM lib references for non-test code,
|
||||
and the `unknown` shape lets chunk-B transports narrow at their own
|
||||
seam. The plugin's metadata id is suffixed with the participant id so
|
||||
OTel emissions keyed by plugin id distinguish per-peer rates even
|
||||
inside a single session. `processBlock` writes zeros into every output
|
||||
channel rather than allocating a fresh buffer because
|
||||
`AbstractAudioFxPlugin` already supplies fixed-length output arrays;
|
||||
this keeps the inner loop free of allocations under steady-state
|
||||
playback. The Zod boundary on options + preset matches the convention
|
||||
used by sibling source plugins (`audio-fx-plugin-microphone`) so
|
||||
consumers see consistent error shapes across the family.
|
||||
91
docs/ui/react/react-audio-stream/src/telemetry/telemetry.md
Normal file
91
docs/ui/react/react-audio-stream/src/telemetry/telemetry.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `telemetry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The telemetry module owns every OpenTelemetry instrument published by
|
||||
`@suaveplan/react-audio-stream`. Concentrating the OTel surface in one
|
||||
file means the session class never touches `@opentelemetry/api` and
|
||||
tests can verify call counts by spying on a single verb-shaped object
|
||||
instead of low-level meter handles. The shape matches Requirement 7 of
|
||||
the parent spec — two counters, a gauge, two more counters, and a
|
||||
histogram — so the runtime instrument set can be cross-checked against
|
||||
the spec on every change.
|
||||
|
||||
Every emission is no-op until a host registers an OTel SDK provider
|
||||
via `@suaveplan/opentelemetry`. The module-scope meter handles are
|
||||
cheap (one map lookup) so we can keep them at file scope without
|
||||
inflating cold-start cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Counter `react_audio_stream_peer_connect_total{transport}`.
|
||||
- Counter `react_audio_stream_peer_disconnect_total{reason}`, where
|
||||
`reason` covers every `InterruptionClass` plus `explicit`.
|
||||
- UpDown counter `react_audio_stream_active_sessions` (gauge-equivalent
|
||||
via OTel's synchronous up/down counter).
|
||||
- Counter `react_audio_stream_reconnect_attempt_total`.
|
||||
- Counter `react_audio_stream_reconnect_failed_total`.
|
||||
- Histogram `react_audio_stream_rtt_ms{participant}` for the
|
||||
transport's RTT probe.
|
||||
- Verbs (`peerConnect`, `peerDisconnect`, …) keep call sites tidy and
|
||||
give tests one knob per spec requirement.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { streamTelemetry } from "@suaveplan/react-audio-stream";
|
||||
|
||||
streamTelemetry.peerConnect("webrtc");
|
||||
streamTelemetry.recordRtt("alice", 42);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { streamTelemetry } from "@suaveplan/react-audio-stream";
|
||||
|
||||
class ReconnectLadder {
|
||||
async run(verdict: InterruptionVerdict): Promise<void> {
|
||||
for (let attempt = 1; attempt <= this.max; attempt++) {
|
||||
streamTelemetry.reconnectAttempt();
|
||||
try {
|
||||
await this.session.connect(this.roomId);
|
||||
return;
|
||||
} catch {
|
||||
await this.backoff(attempt);
|
||||
}
|
||||
}
|
||||
streamTelemetry.reconnectFailed();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface StreamTelemetry` — verb surface consumed by the session.
|
||||
- `const streamTelemetry: StreamTelemetry` — singleton implementation.
|
||||
- `type DisconnectReason` — `"network" | "media-permission" |
|
||||
"signalling" | "peer" | "unknown" | "explicit"`.
|
||||
- `type TransportLabel` — `"webrtc" | "websocket" | "trystero" |
|
||||
"local"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel's `Gauge` requires async observable callbacks which complicates
|
||||
the testable verb surface; the spec wording reads as "gauge tracks
|
||||
active sessions" and OTel's `UpDownCounter` is the synchronous
|
||||
equivalent that satisfies the same semantics — every monitoring backend
|
||||
renders it as a gauge. The reason labels are constrained to the
|
||||
`DisconnectReason` union so a typo in the call site is a compile
|
||||
error rather than a silent cardinality leak. Transport labels are
|
||||
constrained the same way — adding a new transport in chunk B requires
|
||||
extending the union (one line) so backend dashboards stay in sync with
|
||||
the code automatically. The participant id is used as a histogram
|
||||
attribute even though it is high-cardinality because RTT samples are
|
||||
per-peer by nature; consumers wiring a low-cardinality backend
|
||||
(Prometheus) should drop the attribute via a view in their SDK
|
||||
configuration rather than us removing it here.
|
||||
59
docs/ui/react/react-audio-stream/src/test-setup.md
Normal file
59
docs/ui/react/react-audio-stream/src/test-setup.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# test-setup
|
||||
|
||||
## Purpose
|
||||
|
||||
`test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s
|
||||
`setupFiles`. It registers `@testing-library/jest-dom`'s custom matchers
|
||||
(`toBeInTheDocument`, `toHaveAttribute`, `toHaveTextContent`, ...) onto Vitest's
|
||||
`expect`, and it installs a global `afterEach` hook that runs
|
||||
`@testing-library/react`'s `cleanup()` so each test starts with a fresh DOM tree.
|
||||
|
||||
The auto-cleanup that ships with `@testing-library/react` is gated on
|
||||
`globalThis.afterEach` being present at import time, which is not the case under
|
||||
Vitest's `globals: false` mode used by this package — hence the explicit hook here.
|
||||
|
||||
## Features
|
||||
|
||||
- Loads `@testing-library/jest-dom/vitest` (Vitest-native matcher extension).
|
||||
- Installs an `afterEach` hook to call `cleanup()`.
|
||||
- Runs once per worker process; idempotent across re-imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
The file is referenced by `vitest.config.ts`:
|
||||
|
||||
```ts
|
||||
setupFiles: ["@suaveplan/testing/polyfills", "./src/test-setup.ts"],
|
||||
```
|
||||
|
||||
Consumers do not import it directly.
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
To add another test-time global (e.g. a fake `localStorage` reset), extend this file:
|
||||
|
||||
```ts
|
||||
import { afterEach } from "@suaveplan/testing/runner";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports nothing. Side effects only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `@testing-library/jest-dom/vitest` entry point automatically calls
|
||||
`expect.extend(matchers)` for Vitest's `expect` singleton, which is why no explicit
|
||||
`expect.extend` call is needed here. Importing the package root instead (without
|
||||
`/vitest`) would attach the matchers to Jest's globals — wrong runtime for this
|
||||
package, and a silent no-op under Vitest because the matchers never see the right
|
||||
`expect`. The two import order also matters: jest-dom must be loaded before any
|
||||
test file executes so the matcher patches are visible when the first test runs.
|
||||
Listing this file second in `setupFiles` (after the polyfills bootstrap) preserves
|
||||
that order.
|
||||
65
docs/ui/react/react-audio-stream/src/test-utils.md
Normal file
65
docs/ui/react/react-audio-stream/src/test-utils.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# test-utils
|
||||
|
||||
## Purpose
|
||||
|
||||
`test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used
|
||||
by every chunk-C hook + component test. The fake mirrors the canonical `StreamSessionLike`
|
||||
contract from the spec — same method names, same event channels — so tests exercise the
|
||||
real React render path without booting transports or audio engines. Tests inject the
|
||||
fake via the provider's `sessionFactory`, drive state mutations through the public API,
|
||||
and assert the rendered DOM, hook return value, and recorded method calls.
|
||||
|
||||
The file lives at the package root rather than inside a feature directory because it is
|
||||
shared by every feature module; `vitest.config.ts` excludes it from coverage.
|
||||
|
||||
## Features
|
||||
|
||||
- `FakeSession` — `StreamSessionLike` implementation with synchronous mutators, an
|
||||
internal `calls` recorder, and helpers (`pushPeer`, `setMixer`, `triggerInterruption`)
|
||||
that emit the matching event channel.
|
||||
- `withProvider(session)` — returns a `wrapper` suitable for
|
||||
`renderHook`/`render({ wrapper })`.
|
||||
- Strongly typed: every method signature matches the canonical spec so a compile-time
|
||||
break in chunks A+B surfaces here first.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamSession } from "./use-stream-session.js";
|
||||
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamSession(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const session = new FakeSession({
|
||||
state: "joined",
|
||||
peers: [{ id: "alice", displayName: "Alice", connectionState: "connected", rttMs: 32 }],
|
||||
});
|
||||
// Drive state changes deterministically:
|
||||
await act(async () => session.pushPeer({ id: "bob", ... }));
|
||||
expect(result.current.peers).toHaveLength(2);
|
||||
expect(session.calls.setTrackVolume).toEqual([["alice", -3]]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FakeSessionInit` — constructor options bag.
|
||||
- `FakeSession` — `StreamSessionLike` plus `calls`, `pushPeer`, `setPeers`, `setMixer`,
|
||||
`setLocalLevel`, `setPeerLevel`, `triggerInterruption`.
|
||||
- `withProvider(session)` — `(props: { children }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The fake's event-emit path is synchronous (no microtask hop) so test assertions can run
|
||||
immediately after the mutation without `act` for simple cases. When tests need to
|
||||
observe a React re-render fence, they wrap the mutator in `act(() => session.foo())`.
|
||||
The recorded `calls` arrays let solo-semantics tests verify that mutations route through
|
||||
the session's `setTrackVolume` / `setTrackMute` / `setTrackSolo` rather than being
|
||||
re-implemented inside the hook.
|
||||
@@ -0,0 +1,76 @@
|
||||
# control-channel
|
||||
|
||||
## Purpose
|
||||
|
||||
The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow between the granting peer (the "driver") and every connected receiver, and it implements the `TransportShareCoordinator` — a small state-aware adapter that wires a local `SharedTransportTarget` to a peer-level `ControlChannel`.
|
||||
|
||||
The coordinator is the only place in the package where transport events become wire messages. Everything else — hooks, components, session glue — observes the coordinator's mode and applied envelopes rather than touching the transport or channel directly. Keeping the translation pinned to one module means the protocol is straightforward to audit and to evolve without touching React code paths.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-envelope wire protocol: `play`, `pause`, `seek`, `setTempo`.
|
||||
- Driver-side listener attaches to the transport's `start`, `pause`, and `bpm` events and forwards each as the matching envelope.
|
||||
- Receiver-side listener applies each inbound envelope to the local transport target via `start`, `pause`, `seek`, `setTempo`.
|
||||
- Mode-flip safety: switching between `local`, `driving`, and `receiving` always detaches the previous direction's listeners with no leaks.
|
||||
- Imperative `notifySeek(seconds)` for callers performing programmatic seeks (the underlying transport does not emit a `seek` event).
|
||||
- `onModeChange` and `onEnvelope` subscriptions for higher layers (the hook).
|
||||
- `dispose()` releases all listeners and the channel subscription.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTransportShareCoordinator,
|
||||
type ControlChannel,
|
||||
type SharedTransportTarget,
|
||||
} from "@suaveplan/react-audio-stream/transport-share";
|
||||
|
||||
declare const transport: SharedTransportTarget;
|
||||
declare const channel: ControlChannel;
|
||||
|
||||
const coordinator = createTransportShareCoordinator({ transport, channel });
|
||||
coordinator.drive(); // become the driver — local events now propagate
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const coordinator = createTransportShareCoordinator({ transport, channel });
|
||||
|
||||
const unsubscribeMode = coordinator.onModeChange((mode) => {
|
||||
console.info("share role is now", mode);
|
||||
});
|
||||
|
||||
const unsubscribeEnvelope = coordinator.onEnvelope((envelope) => {
|
||||
if (envelope.type === "setTempo") {
|
||||
console.info("tempo synced to", envelope.bpm);
|
||||
}
|
||||
});
|
||||
|
||||
coordinator.receive(); // apply inbound envelopes to the local transport
|
||||
// ... share session runs ...
|
||||
coordinator.release(); // return to local-only mode
|
||||
unsubscribeMode();
|
||||
unsubscribeEnvelope();
|
||||
coordinator.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createTransportShareCoordinator(opts): TransportShareCoordinator` — factory.
|
||||
- `TransportShareCoordinator`
|
||||
- `getMode(): TransportShareMode`
|
||||
- `drive(): void`
|
||||
- `receive(): void`
|
||||
- `release(): void`
|
||||
- `notifySeek(seconds: number): void`
|
||||
- `onModeChange(listener): () => void`
|
||||
- `onEnvelope(listener): () => void`
|
||||
- `dispose(): void`
|
||||
- `TransportShareControlMessage` — discriminated union of envelope types.
|
||||
- `ControlChannel` — `{ send, onMessage }` adapter implemented by chunk A's session over the peer transport.
|
||||
- `SharedTransportTarget` — structural `{ start, pause, seek, setTempo, on }` shape that the real `Transport` satisfies via the `useTransport` adapter from `@suaveplan/react-audio`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinator is intentionally stateless beyond its current mode and the cached detach callbacks. Mode changes are atomic: detach previous direction, attach new direction, then flip the role flag and notify subscribers. The driver-side listener defensively reads `payload.time` / `payload.offset` / `payload.bpm` as `number | undefined` because the upstream `TransportEventMap` declares those keys as plain `unknown`; the coordinator coerces missing values to safe defaults rather than relying on transport internals. Disposal is idempotent so the React hook's cleanup effect can call it on unmount without guards. The factory exists so tests can construct the coordinator with the same call shape consumers use through the package's barrel.
|
||||
@@ -0,0 +1,67 @@
|
||||
# share-state
|
||||
|
||||
## Purpose
|
||||
|
||||
The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'` mode. It is intentionally separate from the wire-level coordinator: the state machine is a tiny, framework-free object that the hook can subscribe to without ever touching transport listeners.
|
||||
|
||||
Splitting the two concerns keeps the React layer cheap. The hook only needs to re-render when the role flips, the driver identity changes, or the audience grows — not on every envelope. The coordinator handles per-envelope mechanics; the state machine handles role transitions.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-role state: `local`, `driving`, `receiving`.
|
||||
- Pending-request bookkeeping: `request(participantId)` stores a pending invitation without changing role.
|
||||
- `grant(selfId, receiverId)` — transition into `'driving'` and add receivers to the audience.
|
||||
- `receiveFrom(driverId)` — transition into `'receiving'`.
|
||||
- `removeReceiver(receiverId, releaseOnEmpty)` — drop a receiver; optionally fall back to `'local'` when the audience is empty.
|
||||
- `driverDisconnect()` — receiver-side hook for the spec scenario "driver disconnect returns share status to local".
|
||||
- `release()` — voluntarily exit any active share role.
|
||||
- Immutable snapshots returned from every method and emitted to subscribers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ShareStateMachine } from "@suaveplan/react-audio-stream/transport-share";
|
||||
|
||||
const machine = new ShareStateMachine();
|
||||
machine.grant("self-id", "alice");
|
||||
console.log(machine.getState().role); // "driving"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const machine = new ShareStateMachine();
|
||||
const unsubscribe = machine.subscribe((state) => {
|
||||
if (state.role === "receiving") {
|
||||
console.info("now receiving from", state.driverId);
|
||||
}
|
||||
});
|
||||
|
||||
machine.request("bob"); // ask bob to drive
|
||||
machine.receiveFrom("bob"); // bob granted us — role flips to receiving
|
||||
machine.driverDisconnect(); // bob left mid-share — back to local
|
||||
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ShareStateMachine`
|
||||
- `getState(): ShareState`
|
||||
- `request(participantId): ShareState`
|
||||
- `hasPendingRequest(participantId): boolean`
|
||||
- `grant(selfId, receiverId?): ShareState`
|
||||
- `receiveFrom(driverId): ShareState`
|
||||
- `removeReceiver(receiverId, releaseOnEmpty?): ShareState`
|
||||
- `driverDisconnect(): ShareState`
|
||||
- `release(): ShareState`
|
||||
- `subscribe(listener): () => void`
|
||||
- `ShareRole` — `'local' | 'driving' | 'receiving'`.
|
||||
- `ShareState` — `{ role, driverId, receivers }` immutable snapshot.
|
||||
- `ShareStateListener` — `(state: ShareState) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Snapshots are produced eagerly with `[...receivers]` spreads so external consumers can hold references across transitions without observing in-place mutation. The internal `Set<ShareStateListener>` allows duplicate subscriptions to be deduplicated and resists listener-leakage if the caller forgets to unsubscribe — the React hook always pairs subscribe with an effect-cleanup return so the leak is bounded to component lifetime.
|
||||
|
||||
Transitions are deliberately permissive: calling `release()` from `'local'` is a no-op rather than a throw, matching the spec rule "controls on receivers SHALL no-op without throwing". `grant()` while already driving extends the audience instead of resetting it — multiple receivers can join an in-flight share without re-broadcasting the initial play state. `driverDisconnect()` is a guarded transition that only acts in `'receiving'`; if a stale ICE-disconnect event arrives after the local peer has already exited share mode, the call is a safe no-op.
|
||||
81
docs/ui/react/react-audio-stream/src/transports/transport.md
Normal file
81
docs/ui/react/react-audio-stream/src/transports/transport.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Stream transport adapters — shared types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `transport.types.ts` module declares the structural contract that
|
||||
`StreamSession` (authored in chunk A of the `@suaveplan/react-audio-stream`
|
||||
split) accepts via constructor injection. By co-locating the contract with
|
||||
the three concrete adapters (WebRTC, WebSocket, Trystero) in chunk B we
|
||||
guarantee every adapter conforms to the same surface and the session can
|
||||
hot-swap them without re-implementation. The shapes mirror the canonical
|
||||
specification
|
||||
so chunk-A and chunk-B merges remain structurally compatible.
|
||||
|
||||
## Features
|
||||
|
||||
- `StreamTransportAdapter` — DI contract for any peer-to-peer audio transport.
|
||||
- `TransportEvent` — discriminated union covering connect, disconnect,
|
||||
inbound track, and RTT-probe ticks.
|
||||
- `SignallingChannel` / `SignallingMessage` — transport-neutral signalling
|
||||
envelope used by the WebRTC adapter (callers MUST plug their own channel).
|
||||
- `IceConfig` — STUN/TURN configuration with no embedded defaults.
|
||||
- `InterruptionClass` / `InterruptionCause` — disconnect-cause classification
|
||||
fed into chunk A's telemetry histogram.
|
||||
- `ReactAudioStreamErrorLike` — local mirror of chunk A's error shape so
|
||||
chunk-B tests can assert on `.code` without a circular merge dependency.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { StreamTransportAdapter } from "@suaveplan/react-audio-stream/transports";
|
||||
|
||||
function logId(adapter: StreamTransportAdapter): void {
|
||||
console.log(adapter.id);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
StreamTransportAdapter,
|
||||
TransportEvent,
|
||||
} from "@suaveplan/react-audio-stream/transports";
|
||||
|
||||
function watch(adapter: StreamTransportAdapter): () => void {
|
||||
return adapter.subscribe((event: TransportEvent) => {
|
||||
switch (event.type) {
|
||||
case "connected":
|
||||
console.log("peer joined", event.peerId);
|
||||
break;
|
||||
case "disconnected":
|
||||
console.warn("peer dropped", event.class, event.cause);
|
||||
break;
|
||||
case "rtt":
|
||||
console.debug("rtt", event.peerId, event.ms, "ms");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamTransportAdapter.connect(roomId)` — join a named room.
|
||||
- `StreamTransportAdapter.disconnect()` — leave + tear down peer state.
|
||||
- `StreamTransportAdapter.send(peerId, payload)` — unicast bytes.
|
||||
- `StreamTransportAdapter.broadcast(payload)` — fan-out bytes.
|
||||
- `StreamTransportAdapter.getRtt(peerId?)` — sample round-trip in ms.
|
||||
- `StreamTransportAdapter.subscribe(handler)` — wire `TransportEvent` flow.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The contract is intentionally minimal: it omits opinionated state-machine
|
||||
hooks because `StreamSession` owns its own FSM (chunk A). Adapters MUST
|
||||
report disconnects with a fully populated `InterruptionCause` so chunk A's
|
||||
`InterruptionClassifier` can map ICE state, close code, or media-permission
|
||||
error names without probabilistic heuristics.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Trystero transport adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TrysteroTransport` is the serverless `StreamTransportAdapter` powered by
|
||||
`@suaveplan/network-discovery-trystero`. It piggybacks on BitTorrent,
|
||||
Nostr, MQTT, or IPFS as a discovery + signalling layer so consumers can
|
||||
host WebRTC sessions without operating any back end of their own. This
|
||||
adapter is the ONLY file in `@suaveplan/react-audio-stream` that touches
|
||||
the trystero package; every other surface excludes it from the bundle
|
||||
graph thanks to the subpath gate and the lazy `loader`.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy loader injection — trystero is only imported when the caller
|
||||
actually constructs this adapter.
|
||||
- Maps `room.onPeerJoin` / `room.onPeerLeave` directly to the shared
|
||||
`TransportEvent` flow (`connected`, `disconnected`).
|
||||
- Unicast via `makeAction("audio")` with the `targets` argument; broadcast
|
||||
via the same action with `undefined` targets.
|
||||
- RTT probe via `room.ping(peerId)` (Trystero's built-in primitive).
|
||||
- Loader failure surfaces as `REACT_AUDIO_STREAM.SIGNALLING_FAILED` with
|
||||
the underlying error attached as `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TrysteroTransport } from "@suaveplan/react-audio-stream/transports/trystero";
|
||||
|
||||
const transport = new TrysteroTransport({
|
||||
id: "tst-1",
|
||||
appId: "myapp",
|
||||
localPeerId: "alice",
|
||||
loader: () => import("@suaveplan/network-discovery-trystero"),
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const transport = new TrysteroTransport({
|
||||
id: "tst-2",
|
||||
appId: "myapp",
|
||||
localPeerId: "alice",
|
||||
loader: () => import("@suaveplan/network-discovery-trystero"),
|
||||
});
|
||||
const off = transport.subscribe((event) => {
|
||||
if (event.type === "connected") console.log("peer joined", event.peerId);
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
await transport.broadcast(new Uint8Array([1, 2, 3]));
|
||||
const rtt = await transport.getRtt();
|
||||
console.log("rtt", rtt, "ms");
|
||||
off();
|
||||
await transport.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `connect(roomId)` — lazily loads trystero + joins the room.
|
||||
- `send(peerId, payload)` — unicast via the `audio` action.
|
||||
- `broadcast(payload)` — fan-out via the same action.
|
||||
- `getRtt(peerId?)` — `room.ping(peerId)` in ms.
|
||||
- `subscribe(handler)` — register a `TransportEvent` listener.
|
||||
- `disconnect()` — calls `room.leave()` and releases the action handle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`@suaveplan/network-discovery-trystero` is declared as an optional peer
|
||||
dependency. The chunk-B branch does not modify `package.json` (chunk A
|
||||
owns it) so the optional dep + matching `./transports/trystero` subpath
|
||||
export are tracked as a hand-off item for the merge step. The lazy
|
||||
`loader` argument exists so tests can inject a deterministic in-memory
|
||||
room implementation, and so production code never pulls trystero into the
|
||||
WebRTC- or WebSocket-only bundles.
|
||||
@@ -0,0 +1,76 @@
|
||||
# WebRTC transport adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebRtcTransport` is the low-latency default `StreamTransportAdapter` for
|
||||
`@suaveplan/react-audio-stream`. It wires a caller-supplied signalling
|
||||
channel into one `RTCPeerConnection` per remote peer, opens an `RTCDataChannel`
|
||||
for control + audio framing, and reports ICE state transitions through the
|
||||
shared `TransportEvent` flow so chunk A's session FSM can classify
|
||||
disconnects deterministically. The adapter ships **no embedded STUN/TURN
|
||||
defaults** — production deployments MUST supply their own `iceConfig`.
|
||||
|
||||
## Features
|
||||
|
||||
- One peer connection per remote, indexed by signalling source ID.
|
||||
- Outbound offer (`offerTo`) and inbound offer/answer/ICE handshake driven
|
||||
by the injected `SignallingChannel`.
|
||||
- ICE-state-driven disconnect classification (network / peer / unknown).
|
||||
- RTT probe via `getStats()` reading `candidate-pair.currentRoundTripTime`.
|
||||
- Constructor validation: rejects empty `iceServers` with
|
||||
`REACT_AUDIO_STREAM.INVALID_OPTIONS` to enforce explicit configuration.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebRtcTransport } from "@suaveplan/react-audio-stream/transports/webrtc";
|
||||
|
||||
const transport = new WebRtcTransport({
|
||||
id: "rtc-1",
|
||||
localPeerId: "alice",
|
||||
iceConfig: { iceServers: [{ urls: "stun:stun.example.com:3478" }] },
|
||||
signalling: mySignallingChannel,
|
||||
peerConnectionFactory: (cfg) => new RTCPeerConnection(cfg),
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const off = transport.subscribe((event) => {
|
||||
if (event.type === "disconnected" && event.class === "network") {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
await transport.offerTo("bob");
|
||||
const rtt = await transport.getRtt("bob");
|
||||
console.log("rtt", rtt, "ms");
|
||||
off();
|
||||
await transport.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new WebRtcTransport(config)` — validates `iceServers` length.
|
||||
- `connect(roomId)` — subscribes the signalling channel.
|
||||
- `offerTo(peerId)` — initiate an outbound offer.
|
||||
- `send(peerId, payload)` — unicast over the open data channel.
|
||||
- `broadcast(payload)` — fan-out to every open channel.
|
||||
- `getRtt(peerId?)` — sample `candidate-pair` RTT in milliseconds.
|
||||
- `subscribe(handler)` — register a `TransportEvent` listener.
|
||||
- `disconnect()` — close every peer + detach signalling.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`RTCPeerConnection` is consumed via a structural `PeerConnectionLike` view
|
||||
so deterministic in-memory implementations can drive tests without real
|
||||
ICE. Disconnect classification follows the spec mapping: `failed` /
|
||||
`disconnected` → `network`; `closed` → `peer`; anything else → `unknown`.
|
||||
The adapter never instantiates STUN/TURN itself — embedded defaults would
|
||||
be a security hazard because they would leak peer IPs to whoever operates
|
||||
the default STUN.
|
||||
@@ -0,0 +1,80 @@
|
||||
# WebSocket transport adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebSocketTransport` is the fallback `StreamTransportAdapter` consumers
|
||||
choose when a corporate or carrier NAT blocks WebRTC's UDP path. It
|
||||
multiplexes every remote peer over a single binary WebSocket using a
|
||||
length-prefixed framing header (`target` + `codec` tag + payload) and
|
||||
optionally upgrades raw PCM to Opus when the
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm` encoder is lazily available.
|
||||
|
||||
## Features
|
||||
|
||||
- Binary framing with explicit target peer ID + codec discriminator.
|
||||
- Lazy Opus encoder load with graceful raw-PCM fallback on failure.
|
||||
- Heartbeat ping loop driving server-reported RTT samples.
|
||||
- Disconnect classification by close code (`1000` → peer, otherwise network).
|
||||
- JSON control frames (`join`, `leave`, `pong`) drive connect / disconnect /
|
||||
RTT events without a second socket.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebSocketTransport } from "@suaveplan/react-audio-stream/transports/websocket";
|
||||
|
||||
const transport = new WebSocketTransport({
|
||||
id: "ws-1",
|
||||
url: "wss://relay.example.com/audio",
|
||||
localPeerId: "alice",
|
||||
socketFactory: (url) => new WebSocket(url),
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const transport = new WebSocketTransport({
|
||||
id: "ws-2",
|
||||
url: "wss://relay.example.com/audio",
|
||||
localPeerId: "alice",
|
||||
heartbeatMs: 2000,
|
||||
socketFactory: (url) => new WebSocket(url),
|
||||
opusEncoderLoader: async () => {
|
||||
const mod = await import("@suaveplan/audio-codec-ffmpeg-wasm");
|
||||
return mod.createOpusEncoder();
|
||||
},
|
||||
});
|
||||
const off = transport.subscribe((e) => {
|
||||
if (e.type === "rtt") console.log("rtt", e.peerId, e.ms);
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
await transport.broadcast(new Uint8Array([0, 1, 2]));
|
||||
off();
|
||||
await transport.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `connect(roomId)` — opens the socket and starts the optional heartbeat.
|
||||
- `send(peerId, payload)` — frames + sends binary; throws
|
||||
`REACT_AUDIO_STREAM.SIGNALLING_FAILED` if the socket is not open.
|
||||
- `broadcast(payload)` — sends with `target = "*"`.
|
||||
- `getRtt(peerId?)` — returns the last `pong`-reported RTT in ms.
|
||||
- `subscribe(handler)` — register a `TransportEvent` listener.
|
||||
- `disconnect()` — closes the socket with code `1000`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire framing keeps target IDs at sub-65 KB and codec selection on a
|
||||
single byte for trivial branch prediction. Opus loading is gated behind a
|
||||
caller-supplied loader so the adapter never imports
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm` at module scope — a hard requirement
|
||||
for the bundle-size purity test. Heartbeat scheduling is disabled by
|
||||
default (`heartbeatMs = 0`) so unit tests can drive RTT through synthetic
|
||||
`pong` frames.
|
||||
206
docs/ui/react/react-audio/README.md
Normal file
206
docs/ui/react/react-audio/README.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# @suaveplan/react-audio - React surface for the Suaveplan audio family — Phase 2 + Phase 2b (extras) + Phase 3 shipped. Provides provider + hooks + stores + error codes + a11y helpers + Phase-2 primitives (PluginSlot, PluginChainView, PresetManager, knob/slider) + composed-plugin UIs (TransportControls, TrackList/TrackRow, MasterMeter, Mixer, AudioVisualizer, AudioPlayer/BasicPlayer/PremiumPlayer, MicrophoneControls, GLaDOSControls, PluginLoader) + Phase 3 MIDI learn-mode (`<MidiControllerSetup>` + `useMidiController`).
|
||||
|
||||
React surface for the Suaveplan audio family. **Phase 1 of 3 —
|
||||
foundation** (provider + hooks + stores + error codes + a11y helpers).
|
||||
Components ship in Phase 2 (`enterprise-react-audio-components`);
|
||||
the MIDI learn-mode panel ships in Phase 3
|
||||
(`enterprise-react-audio-midi`).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [aria-value-text](./src/a11y/aria-value-text/aria-value-text.md) — Pure `aria-valuetext` formatter consumed by Phase-2 parameter controls (knobs, sliders, scrubbers). Returns a screen-re…
|
||||
- [aria-value-text](./src/a11y/aria-value-text/aria-value-text.md) — Pure `aria-valuetext` formatter consumed by Phase-2 parameter controls (knobs, sliders, scrubbers). Returns a screen-re…
|
||||
- [keyboard-bindings](./src/a11y/keyboard-bindings/keyboard-bindings.md) — Pure-value table mapping keyboard keys to parameter-control actions (`fine-decrement`, `coarse-increment`, `page-decrem…
|
||||
- [keyboard-bindings](./src/a11y/keyboard-bindings/keyboard-bindings.md) — Pure-value table mapping keyboard keys to parameter-control actions (`fine-decrement`, `coarse-increment`, `page-decrem…
|
||||
- [audio-player](./src/components/audio-player/audio-player.md) — `<AudioPlayer>` is the generic media-player surface for a live `AudioPlayerPlugin` instance from `@suaveplan/audio-fx-p…
|
||||
- [audio-player](./src/components/audio-player/audio-player.md) — `<AudioPlayer>` is the generic media-player surface for a live `AudioPlayerPlugin` instance from `@suaveplan/audio-fx-p…
|
||||
- [audio-visualizer](./src/components/audio-visualizer/audio-visualizer.md) — `<AudioVisualizer>` is a canvas-based visualiser for a published bus. It subscribes through the foundation `useBus` hoo…
|
||||
- [audio-visualizer](./src/components/audio-visualizer/audio-visualizer.md) — `<AudioVisualizer>` is a canvas-based visualiser for a published bus. It subscribes through the foundation `useBus` hoo…
|
||||
- [basic-player](./src/components/basic-player/basic-player.md) — `<BasicPlayer>` is the minimal media-player surface for an `AudioPlayerPlugin`. It is implemented as a thin composition…
|
||||
- [basic-player](./src/components/basic-player/basic-player.md) — `<BasicPlayer>` is the minimal media-player surface for an `AudioPlayerPlugin`. It is implemented as a thin composition…
|
||||
- [glados-controls](./src/components/glados-controls/glados-controls.md) — `<GLaDOSControls>` is the composed UI for the `GLaDOSPlugin` voice transform from `@suaveplan/audio-fx-plugin-glados`.…
|
||||
- [glados-controls](./src/components/glados-controls/glados-controls.md) — `<GLaDOSControls>` is the composed UI for the `GLaDOSPlugin` voice transform from `@suaveplan/audio-fx-plugin-glados`.…
|
||||
- [parameter-keyboard](./src/components/internal/parameter-keyboard.md) — Internal helper consumed by `<PluginParameterKnob>` and `<PluginParameterSlider>` to translate a `KeyboardEvent` into a…
|
||||
- [parameter-unit](./src/components/internal/parameter-unit.md) — Internal helper that converts the controlled `ParameterUnit` enum exposed by `@suaveplan/audio-fx-plugin-base` (`"db"`,…
|
||||
- [master-meter](./src/components/master-meter/master-meter.md) — `<MasterMeter>` is a RAF-sampled master-output level meter. It subscribes to a named sidechain bus through the foundati…
|
||||
- [master-meter](./src/components/master-meter/master-meter.md) — `<MasterMeter>` is a RAF-sampled master-output level meter. It subscribes to a named sidechain bus through the foundati…
|
||||
- [microphone-controls](./src/components/microphone-controls/microphone-controls.md) — `<MicrophoneControls>` is the composed UI for `MicrophoneSimulationPlugin` from `@suaveplan/audio-fx-plugin-microphone`…
|
||||
- [microphone-controls](./src/components/microphone-controls/microphone-controls.md) — `<MicrophoneControls>` is the composed UI for `MicrophoneSimulationPlugin` from `@suaveplan/audio-fx-plugin-microphone`…
|
||||
- [midi-controller-setup](./src/components/midi-controller-setup/midi-controller-setup.md) — `<MidiControllerSetup>` is the Phase-3 learn-mode panel for the Suaveplan React audio stack. It renders the connected c…
|
||||
- [midi-controller-setup](./src/components/midi-controller-setup/midi-controller-setup.md) — `<MidiControllerSetup>` is the Phase-3 learn-mode panel for the Suaveplan React audio stack. It renders the connected c…
|
||||
- [mixer](./src/components/mixer/mixer.md) — `<Mixer>` is the per-channel strip view for a live `MixerPlugin` instance from `@suaveplan/audio-fx-plugin-mixer`. It r…
|
||||
- [mixer](./src/components/mixer/mixer.md) — `<Mixer>` is the per-channel strip view for a live `MixerPlugin` instance from `@suaveplan/audio-fx-plugin-mixer`. It r…
|
||||
- [plugin-chain-view](./src/components/plugin-chain-view/plugin-chain-view.md) — Reorderable, accessible view of a `PluginChain`. Renders one `<PluginSlot>` per chain entry, supports HTML5 drag-and-dr…
|
||||
- [plugin-chain-view](./src/components/plugin-chain-view/plugin-chain-view.md) — Reorderable, accessible view of a `PluginChain`. Renders one `<PluginSlot>` per chain entry, supports HTML5 drag-and-dr…
|
||||
- [plugin-loader](./src/components/plugin-loader/plugin-loader.md) — `<PluginLoader>` is the dynamic plugin-registration UI over the provider's `PluginRegistry`. It lists every currently-r…
|
||||
- [plugin-loader](./src/components/plugin-loader/plugin-loader.md) — `<PluginLoader>` is the dynamic plugin-registration UI over the provider's `PluginRegistry`. It lists every currently-r…
|
||||
- [plugin-parameter-knob](./src/components/plugin-parameter-knob/plugin-parameter-knob.md) — Rotary-knob primitive for a single `AudioFxPlugin` parameter. The knob implements the full keyboard contract mandated b…
|
||||
- [plugin-parameter-knob](./src/components/plugin-parameter-knob/plugin-parameter-knob.md) — Rotary-knob primitive for a single `AudioFxPlugin` parameter. The knob implements the full keyboard contract mandated b…
|
||||
- [plugin-parameter-slider](./src/components/plugin-parameter-slider/plugin-parameter-slider.md) — Linear-slider primitive for a single `AudioFxPlugin` parameter. Mirrors `<PluginParameterKnob>`'s a11y contract and exp…
|
||||
- [plugin-parameter-slider](./src/components/plugin-parameter-slider/plugin-parameter-slider.md) — Linear-slider primitive for a single `AudioFxPlugin` parameter. Mirrors `<PluginParameterKnob>`'s a11y contract and exp…
|
||||
- [plugin-slot](./src/components/plugin-slot/plugin-slot.md) — Generic renderer for any `AudioFxPlugin`. Reads the plugin's declarative `parameters` array and renders each one throug…
|
||||
- [plugin-slot](./src/components/plugin-slot/plugin-slot.md) — Generic renderer for any `AudioFxPlugin`. Reads the plugin's declarative `parameters` array and renders each one throug…
|
||||
- [premium-player](./src/components/premium-player/premium-player.md) — `<PremiumPlayer>` is the full-featured media-player surface — a composition of `<AudioPlayer>`, `<AudioVisualizer>`, an…
|
||||
- [premium-player](./src/components/premium-player/premium-player.md) — `<PremiumPlayer>` is the full-featured media-player surface — a composition of `<AudioPlayer>`, `<AudioVisualizer>`, an…
|
||||
- [preset-manager](./src/components/preset-manager/preset-manager.md) — `ChainSnapshot` save / load / delete UI layered over the provider's preset store. The save path persists the snapshot t…
|
||||
- [preset-manager](./src/components/preset-manager/preset-manager.md) — `ChainSnapshot` save / load / delete UI layered over the provider's preset store. The save path persists the snapshot t…
|
||||
- [track-list](./src/components/track-list/track-list.md) — `<TrackList>` is a composed-plugin UI that renders every track in the provider's `TimelineEngine`. It is the canonical…
|
||||
- [track-list](./src/components/track-list/track-list.md) — `<TrackList>` is a composed-plugin UI that renders every track in the provider's `TimelineEngine`. It is the canonical…
|
||||
- [track-row](./src/components/track-row/track-row.md) — `<TrackRow>` is the per-track row primitive consumed by `<TrackList>`. It subscribes to a single `Track` through the fo…
|
||||
- [track-row](./src/components/track-row/track-row.md) — `<TrackRow>` is the per-track row primitive consumed by `<TrackList>`. It subscribes to a single `Track` through the fo…
|
||||
- [transport-controls](./src/components/transport-controls/transport-controls.md) — `<TransportControls>` is the composed-plugin UI for the provider-owned `Transport` instance. It exposes the spec-mandat…
|
||||
- [transport-controls](./src/components/transport-controls/transport-controls.md) — `<TransportControls>` is the composed-plugin UI for the provider-owned `Transport` instance. It exposes the spec-mandat…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code thrown inside `@suaveplan/react-audio`. The codes are na…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code thrown inside `@suaveplan/react-audio`. The codes are na…
|
||||
- [hooks](./src/hooks/use-provider-state.md) — Internal helpers shared across every public hook. They centralise the two structured-error throws every engine-dependen…
|
||||
- [use-analyser](./src/hooks/use-analyser/use-analyser.md) — `useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and returns the latest byte-magnitude FFT frame plus the mat…
|
||||
- [use-analyser](./src/hooks/use-analyser/use-analyser.md) — `useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and returns the latest byte-magnitude FFT frame plus the mat…
|
||||
- [use-audio-engine](./src/hooks/use-audio-engine/use-audio-engine.md) — `useAudioEngine()` returns the full resolved `ProviderState` bundle — the adapter, the `TimelineEngine`, the `Transport…
|
||||
- [use-audio-engine](./src/hooks/use-audio-engine/use-audio-engine.md) — `useAudioEngine()` returns the full resolved `ProviderState` bundle — the adapter, the `TimelineEngine`, the `Transport…
|
||||
- [use-audio-fx-plugin](./src/hooks/use-audio-fx-plugin/use-audio-fx-plugin.md) — `useAudioFxPlugin(pluginType)` instantiates a plugin via the provider's `PluginRegistry.create(pluginType)` and memoise…
|
||||
- [use-audio-fx-plugin](./src/hooks/use-audio-fx-plugin/use-audio-fx-plugin.md) — `useAudioFxPlugin(pluginType)` instantiates a plugin via the provider's `PluginRegistry.create(pluginType)` and memoise…
|
||||
- [use-bus](./src/hooks/use-bus/use-bus.md) — `useBus(name)` reads the named sidechain bus from the provider's `ProviderBusRegistry` and subscribes to its publish/cl…
|
||||
- [use-bus](./src/hooks/use-bus/use-bus.md) — `useBus(name)` reads the named sidechain bus from the provider's `ProviderBusRegistry` and subscribes to its publish/cl…
|
||||
- [use-chain-plugin](./src/hooks/use-chain-plugin/use-chain-plugin.md) — `useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin` currently occupying a specific slot in a track's `Plug…
|
||||
- [use-chain-plugin](./src/hooks/use-chain-plugin/use-chain-plugin.md) — `useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin` currently occupying a specific slot in a track's `Plug…
|
||||
- [use-chain-snapshot](./src/hooks/use-chain-snapshot/use-chain-snapshot.md) — `useChainSnapshot(chain)` exposes chain-level snapshot operations. `get` reads the in-memory `ChainSnapshot`, `restore`…
|
||||
- [use-chain-snapshot](./src/hooks/use-chain-snapshot/use-chain-snapshot.md) — `useChainSnapshot(chain)` exposes chain-level snapshot operations. `get` reads the in-memory `ChainSnapshot`, `restore`…
|
||||
- [use-engine-state](./src/hooks/use-engine-state/use-engine-state.md) — `useEngineState()` reports the `<AudioProvider>`'s async-init lifecycle phase as one of `'pending' | 'ready' | 'error'`…
|
||||
- [use-engine-state](./src/hooks/use-engine-state/use-engine-state.md) — `useEngineState()` reports the `<AudioProvider>`'s async-init lifecycle phase as one of `'pending' | 'ready' | 'error'`…
|
||||
- [use-midi-controller](./src/hooks/use-midi-controller/use-midi-controller.md) — `useMidiController(controllerId)` is the React 19 binding for a single `MidiControllerPlugin` from `@suaveplan/audio-co…
|
||||
- [use-midi-controller](./src/hooks/use-midi-controller/use-midi-controller.md) — `useMidiController(controllerId)` is the React 19 binding for a single `MidiControllerPlugin` from `@suaveplan/audio-co…
|
||||
- [use-parameter](./src/hooks/use-parameter/use-parameter.md) — `useParameter(plugin, paramId)` is the canonical read/write hook for a single declared parameter on any `AudioFxPlugin`…
|
||||
- [use-parameter](./src/hooks/use-parameter/use-parameter.md) — `useParameter(plugin, paramId)` is the canonical read/write hook for a single declared parameter on any `AudioFxPlugin`…
|
||||
- [use-playlist](./src/hooks/use-playlist/use-playlist.md) — `usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries and exposes the standard playlist verbs (`next`,…
|
||||
- [use-playlist](./src/hooks/use-playlist/use-playlist.md) — `usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries and exposes the standard playlist verbs (`next`,…
|
||||
- [use-plugin-chain](./src/hooks/use-plugin-chain/use-plugin-chain.md) — `usePluginChain(trackId)` returns a handle for the `PluginChain` associated with a track. The provider lazily creates t…
|
||||
- [use-plugin-chain](./src/hooks/use-plugin-chain/use-plugin-chain.md) — `usePluginChain(trackId)` returns a handle for the `PluginChain` associated with a track. The provider lazily creates t…
|
||||
- [use-preset](./src/hooks/use-preset/use-preset.md) — `usePreset(plugin)` exposes plugin-level preset operations as React-friendly callbacks: `get` reads the current preset,…
|
||||
- [use-preset](./src/hooks/use-preset/use-preset.md) — `usePreset(plugin)` exposes plugin-level preset operations as React-friendly callbacks: `get` reads the current preset,…
|
||||
- [use-timeline](./src/hooks/use-timeline/use-timeline.md) — `useTimeline()` returns the provider's single `TimelineEngine` instance, the canonical owner of timeline state — tracks…
|
||||
- [use-timeline](./src/hooks/use-timeline/use-timeline.md) — `useTimeline()` returns the provider's single `TimelineEngine` instance, the canonical owner of timeline state — tracks…
|
||||
- [use-track](./src/hooks/use-track/use-track.md) — `useTrack(trackId)` subscribes to a single track on the `TimelineEngine` by id and re-renders only when that specific t…
|
||||
- [use-track](./src/hooks/use-track/use-track.md) — `useTrack(trackId)` subscribes to a single track on the `TimelineEngine` by id and re-renders only when that specific t…
|
||||
- [use-transport](./src/hooks/use-transport/use-transport.md) — `useTransport()` wraps the provider's `Transport` with a small, React-friendly façade: `{ transport, play, pause, stop,…
|
||||
- [use-transport](./src/hooks/use-transport/use-transport.md) — `useTransport()` wraps the provider's `Transport` with a small, React-friendly façade: `{ transport, play, pause, stop,…
|
||||
- [use-transport-time](./src/hooks/use-transport-time/use-transport-time.md) — `useTransportTime()` returns the transport's current time in seconds, sampled at the provider's configured `sampleRateH…
|
||||
- [use-transport-time](./src/hooks/use-transport-time/use-transport-time.md) — `useTransportTime()` returns the transport's current time in seconds, sampled at the provider's configured `sampleRateH…
|
||||
- [use-provider-state](./src/hooks/use-provider-state.md) — Internal helpers shared across every public hook. They centralise the two structured-error throws every engine-dependen…
|
||||
- [audio-context](./src/provider/audio-context.md) — Declares the React context object (`AudioReactContext`) that `<AudioProvider>` populates and that every hook reads from…
|
||||
- [audio-provider](./src/provider/audio-provider.md) — `<AudioProvider>` is the single React component that owns the audio engine bundle for a React subtree. It constructs ex…
|
||||
- [chain-map](./src/provider/chain-map.md) — `ChainMap` is the provider-scoped lazy registry of `PluginChain` instances, keyed by track id. The hook `usePluginChain…
|
||||
- [provider-bus-registry](./src/provider/provider-bus-registry.md) — `ProviderBusRegistry` is a thin wrapper around `DefaultBusRegistry` from `@suaveplan/audio-fx-plugin-base` that adds a…
|
||||
- [provider-options](./src/provider/provider-options.md) — Defines the `AudioProviderOptions` shape consumed by `<AudioProvider>`, the Zod schema that validates it, and the `pars…
|
||||
- [audio-store](./src/stores/audio-store/audio-store.md) — `useAudioStore` is a slim provider-scoped Zustand store reserved for application-level audio UI overrides (display name…
|
||||
- [audio-store](./src/stores/audio-store/audio-store.md) — `useAudioStore` is a slim provider-scoped Zustand store reserved for application-level audio UI overrides (display name…
|
||||
- [ui-store](./src/stores/ui-store/ui-store.md) — `useUiStore` is the cross-component UI state store for the React audio workspace. It owns presentation-only state — whi…
|
||||
- [ui-store](./src/stores/ui-store/ui-store.md) — `useUiStore` is the cross-component UI state store for the React audio workspace. It owns presentation-only state — whi…
|
||||
- [test-utils](./src/test-utils.md) — Test-only helpers for the `@suaveplan/react-audio` test suite. The module exports three utilities: `makeFakeAudioContex…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/react-audio
|
||||
```
|
||||
|
||||
Peer deps: `react@^19`, `react-dom@^19`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, useTransport } from "@suaveplan/react-audio";
|
||||
|
||||
function PlayButton() {
|
||||
const { play, stop, transport } = useTransport();
|
||||
return (
|
||||
<button type="button" onClick={() => (transport.state === "playing" ? stop() : play())}>
|
||||
{transport.state === "playing" ? "Stop" : "Play"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AudioProvider options={{ adapter: { sampleRate: 48000 } }}>
|
||||
<PlayButton />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## What ships in Phase 1
|
||||
|
||||
### Provider
|
||||
|
||||
- `<AudioProvider>` — owns one `BrowserTimelineAdapter`, one
|
||||
`TimelineEngine`, one `Transport`, one `ProviderBusRegistry`, one
|
||||
`ChainMap`, and one `PluginRegistry`. Strict-mode safe; disposes
|
||||
cleanly on unmount.
|
||||
|
||||
### 13 hooks
|
||||
|
||||
| Hook | Returns |
|
||||
|------|---------|
|
||||
| `useEngineState()` | `'pending' \| 'ready' \| 'error'` |
|
||||
| `useAudioEngine()` | full `ProviderState` bundle |
|
||||
| `useTimeline()` | `TimelineEngine` |
|
||||
| `useTransport()` | `{ transport, play, pause, stop, seek, setTempo }` |
|
||||
| `useTransportTime()` | current seconds, sampled at frame rate |
|
||||
| `useTrack(trackId)` | `{ track, update, remove }` |
|
||||
| `usePluginChain(trackId)` | chain composition + events |
|
||||
| `useChainPlugin(trackId, slot)` | plugin at slot |
|
||||
| `useAudioFxPlugin(pluginType)` | new plugin from the registry |
|
||||
| `useParameter(plugin, paramId)` | value + set + curve + scheduleAt |
|
||||
| `usePreset(plugin)` | plugin-level preset get/apply/save/load |
|
||||
| `useChainSnapshot(chain)` | chain-level snapshot get/restore/save/load |
|
||||
| `useBus(name)` | sidechain bus by name |
|
||||
|
||||
### Stores
|
||||
|
||||
- `useAudioStore` — UI override store (no engine state mirrored).
|
||||
- `useUiStore` — panel/theme/visualization/selectedTrack/dragState.
|
||||
|
||||
### Accessibility helpers
|
||||
|
||||
- `PARAMETER_KEY_BINDINGS` + `matchKeyBinding(event)` — central
|
||||
keymap consumed by Phase-2 components.
|
||||
- `formatAriaValueText(value, unit, min, max, options?)` — unit-aware
|
||||
`aria-valuetext` formatter.
|
||||
|
||||
### Error codes
|
||||
|
||||
All errors are `ReactAudioError extends SuaveplanError`. Codes:
|
||||
|
||||
- `REACT_AUDIO.NO_PROVIDER`
|
||||
- `REACT_AUDIO.PROVIDER_PENDING`
|
||||
- `REACT_AUDIO.UNKNOWN_PARAMETER`
|
||||
- `REACT_AUDIO.PRESET_STORE_FAILED`
|
||||
- `REACT_AUDIO.INVALID_OPTIONS`
|
||||
|
||||
## Subpath exports
|
||||
|
||||
```ts
|
||||
import { AudioProvider } from "@suaveplan/react-audio/provider";
|
||||
import { useTransport } from "@suaveplan/react-audio/hooks/use-transport";
|
||||
import { useAudioStore } from "@suaveplan/react-audio/stores";
|
||||
import { formatAriaValueText } from "@suaveplan/react-audio/a11y";
|
||||
import { ReactAudioError } from "@suaveplan/react-audio/error-codes";
|
||||
```
|
||||
|
||||
`./components` is reserved for Phase 2 and is **not** declared in
|
||||
Phase 1.
|
||||
|
||||
## Coming next
|
||||
|
||||
- **Phase 2 (`enterprise-react-audio-components`)** — Storybook-covered
|
||||
components: `<TransportControls>`, `<TrackList>`, `<PluginChainView>`,
|
||||
`<PluginSlot>`, `<MasterMeter>`, `<Mixer>`, `<PresetManager>`, …
|
||||
- **Phase 3 (`enterprise-react-audio-midi`)** — `useMidiController`
|
||||
hook + `<MidiControllerSetup>` learn-mode panel.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# `a11y/aria-value-text`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure `aria-valuetext` formatter consumed by Phase-2 parameter
|
||||
controls (knobs, sliders, scrubbers). Returns a screen-reader
|
||||
friendly string for any numeric parameter value, picking sensible
|
||||
fractional digits from the parameter's declared range and applying
|
||||
unit-specific conventions (signed dB, kHz autoconversion above 1000
|
||||
Hz, percent suffix, ratio formatting, …).
|
||||
|
||||
The module has zero React or DOM imports so it is safe to use in
|
||||
universal contexts (tests, SSR, Storybook docs, isolation
|
||||
tools).
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-derived fractional digits from value magnitude and parameter
|
||||
range (overridable via `options.fractionDigits`).
|
||||
- Signed dB with optional `+` prefix on positive values (default on).
|
||||
- Hz auto-promotion to kHz above 1000 Hz.
|
||||
- Built-in unit recognisers: `dB`, `Hz`, `kHz`, `ms`, `s`, `%`,
|
||||
`ratio`, `cents`, `semitones`, `samples`, `bpm`; arbitrary strings
|
||||
fall through with `${value.toFixed(digits)} ${unit}`.
|
||||
- Handles `Infinity` and `NaN` by returning `"out of range"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { formatAriaValueText } from "@suaveplan/react-audio/a11y";
|
||||
|
||||
formatAriaValueText(3.5, "dB", -12, 12); // "+3.50 dB"
|
||||
formatAriaValueText(880, "Hz", 20, 20000); // "880 Hz"
|
||||
formatAriaValueText(2400, "Hz", 20, 20000); // "2.40 kHz"
|
||||
formatAriaValueText(50, "%", 0, 100); // "50.0 %"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { formatAriaValueText } from "@suaveplan/react-audio/a11y";
|
||||
|
||||
function ParameterReadout({
|
||||
value,
|
||||
unit,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: number;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}) {
|
||||
return (
|
||||
<span aria-live="polite">
|
||||
{formatAriaValueText(value, unit, min, max, {
|
||||
fractionDigits: 1,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatAriaValueText(value, unit, min, max, options?): string`.
|
||||
- `AriaValueUnit` — union of the recognised unit strings (plus the
|
||||
catch-all `string`).
|
||||
- `FormatAriaValueOptions = { fractionDigits?, signedDb? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The auto-derived precision picks 0 digits for ranges ≥ 1000, 1 digit
|
||||
for ≥ 100, 2 digits for ≥ 1, and 3 digits otherwise. This matches
|
||||
screen-reader-friendly conventions: nobody wants to hear "two
|
||||
thousand four hundred point three two seven six Hertz". Hz values
|
||||
auto-promote to kHz at the 1000 Hz threshold because that's the
|
||||
boundary at which the kHz scale dominates audio production
|
||||
conventions; the kHz output uses a fixed 2-digit precision so values
|
||||
near the boundary read out consistently. `Infinity` and `NaN` short
|
||||
out to `"out of range"` because screen readers handle that phrase
|
||||
gracefully whereas spelling out `Infinity` derails the user.
|
||||
@@ -0,0 +1,121 @@
|
||||
# `a11y/keyboard-bindings`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure-value table mapping keyboard keys to parameter-control actions
|
||||
(`fine-decrement`, `coarse-increment`, `page-decrement`, `min`,
|
||||
`max`, `enter-value`). Phase-2 components — knobs, sliders, the
|
||||
parameter inspector — consume this table directly so the keymap is
|
||||
declared in exactly one place and easy to audit for accessibility
|
||||
compliance.
|
||||
|
||||
The module is React-free; it exports a frozen array, two factor
|
||||
constants (`KEY_MODIFIER_FACTOR_SHIFT` = 10, `KEY_MODIFIER_FACTOR_ALT`
|
||||
= 0.1), and a helper `matchKeyBinding(event)` that resolves a
|
||||
`KeyboardEvent`-shaped value into `{ binding, compositeFactor }` or
|
||||
`undefined` for a no-match.
|
||||
|
||||
## Features
|
||||
|
||||
- `PARAMETER_KEY_BINDINGS` — frozen `ReadonlyArray<KeyBinding>`.
|
||||
- `KEY_MODIFIER_FACTOR_SHIFT`, `KEY_MODIFIER_FACTOR_ALT` — composable
|
||||
multipliers.
|
||||
- `matchKeyBinding(event)` — pure function, no DOM imports.
|
||||
- Standard a11y keymap (arrows, page-up/down, home, end, enter).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { matchKeyBinding } from "@suaveplan/react-audio/a11y";
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
const match = matchKeyBinding(event);
|
||||
if (match === undefined) return;
|
||||
console.log(match.binding.action, match.compositeFactor);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
matchKeyBinding,
|
||||
PARAMETER_KEY_BINDINGS,
|
||||
} from "@suaveplan/react-audio/a11y";
|
||||
|
||||
function Knob({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="slider"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
const match = matchKeyBinding(event);
|
||||
if (match === undefined) return;
|
||||
event.preventDefault();
|
||||
const delta = step * match.compositeFactor;
|
||||
switch (match.binding.action) {
|
||||
case "fine-decrement":
|
||||
case "coarse-decrement":
|
||||
case "page-decrement":
|
||||
onChange(Math.max(min, value - delta));
|
||||
return;
|
||||
case "fine-increment":
|
||||
case "coarse-increment":
|
||||
case "page-increment":
|
||||
onChange(Math.min(max, value + delta));
|
||||
return;
|
||||
case "min":
|
||||
onChange(min);
|
||||
return;
|
||||
case "max":
|
||||
onChange(max);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `KeyBinding` — `{ key, action, factor, modifiers? }`.
|
||||
- `PARAMETER_KEY_BINDINGS: ReadonlyArray<KeyBinding>`.
|
||||
- `KEY_MODIFIER_FACTOR_SHIFT: 10`.
|
||||
- `KEY_MODIFIER_FACTOR_ALT: 0.1`.
|
||||
- `matchKeyBinding(event): { binding, compositeFactor } | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The keymap conventions (`←` fine, `↓` coarse, `Shift` ×10, `Alt`
|
||||
×0.1) match the design.md accessibility section so the upstream
|
||||
behaviour carries forward. The `matchKeyBinding` helper takes an
|
||||
event-shaped object rather than a `KeyboardEvent` so it is callable
|
||||
from test harnesses with plain object literals — and zero DOM types
|
||||
are imported into this file. The factor multiplication
|
||||
order is `binding.factor * shift * alt`; with both modifiers pressed
|
||||
the net effect is `factor × 10 × 0.1 = factor`, which is the
|
||||
intended "shift cancels alt" semantics.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `<AudioPlayer>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<AudioPlayer>` is the generic media-player surface for a live
|
||||
`AudioPlayerPlugin` instance from
|
||||
`@suaveplan/audio-fx-plugin-audio-player`. It renders the full
|
||||
playback control surface — play / pause / stop / seek / volume / loop
|
||||
/ playback rate — bound directly to the plugin's dedicated mutators
|
||||
(`play`, `pause`, `stop`, `seek`, `setVolume`, `setLoop`,
|
||||
`setPlaybackRate`). State is sampled by polling `plugin.getState()` at
|
||||
the configured tick rate so the seek bar stays in sync with the
|
||||
audio-thread position.
|
||||
|
||||
The component is composed entirely against the plugin: there is no
|
||||
React-side cache of playback state. Loading is handled via the
|
||||
optional `src` prop which auto-calls `plugin.load(src)` on mount.
|
||||
|
||||
## Features
|
||||
|
||||
- Full control surface bound to plugin mutators.
|
||||
- Optional `src` triggers async `plugin.load(src)` on mount.
|
||||
- Surfaces plugin / load errors via an inline `role="alert"` element.
|
||||
- `showLoop` / `showPlaybackRate` props toggle the optional controls.
|
||||
- Live seek bar reflects `plugin.getState().currentTimeSec`.
|
||||
- Polls at `sampleRateHz` (default 30, clamped `>= 1`).
|
||||
- Unmounting cancels the in-flight `load()` so late resolutions cannot
|
||||
setState on a torn-down component.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
import { AudioProvider, AudioPlayer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new AudioPlayerPlugin({});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<AudioPlayer plugin={plugin} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
import { AudioProvider, AudioPlayer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new AudioPlayerPlugin({});
|
||||
|
||||
export function Streamer() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<AudioPlayer
|
||||
plugin={plugin}
|
||||
src="https://cdn/track.wav"
|
||||
showPlaybackRate={false}
|
||||
sampleRateHz={60}
|
||||
className="streamer"
|
||||
/>
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ------------------- | -------------------------- | ------- | ------------------------------------------- |
|
||||
| `plugin` | `AudioPlayerPlugin` | — | Required. |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
|
||||
| `src` | `string \| undef` | — | Auto-load this URL on mount. |
|
||||
| `sampleRateHz` | `number \| undef` | `30` | State polling rate. |
|
||||
| `showLoop` | `boolean \| undef` | `true` | When `false`, hides the loop toggle. |
|
||||
| `showPlaybackRate` | `boolean \| undef` | `true` | When `false`, hides the rate input. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Errors thrown by the underlying plugin are caught at every mutation
|
||||
site and surfaced via a single `role="alert"` element so callers can
|
||||
style or speak the error consistently. The cancel-on-unmount guard
|
||||
uses a local `cancelled` flag captured in the effect closure rather
|
||||
than an `AbortController` because `plugin.load` does not expose its
|
||||
inner fetch signal; this still prevents the standard "setState on
|
||||
unmounted component" warning. State polling runs in a separate
|
||||
effect from the load effect so changing `src` does not restart the
|
||||
poll.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `<AudioVisualizer>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<AudioVisualizer>` is a canvas-based visualiser for a published bus.
|
||||
It subscribes through the foundation `useBus` hook (default `"master"`)
|
||||
and renders one of three modes — waveform, spectrum, or level — into a
|
||||
`<canvas>` at the configured tick rate. The component is read-only and
|
||||
allocates exactly one scratch `Float32Array` per mount so the
|
||||
per-frame path is allocation-free in steady state.
|
||||
|
||||
The visualiser is intentionally agnostic about the source: anything
|
||||
that publishes to the named bus shows up. When no publisher exists
|
||||
the canvas paints the background only.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes: `"waveform"`, `"spectrum"`, `"level"`.
|
||||
- Configurable width, height, foreground colour, and background.
|
||||
- `sampleRateHz` controls the tick rate (clamped to `>= 1`).
|
||||
- `busName` selects which bus to render (default `"master"`).
|
||||
- `role="img"` + descriptive `aria-label` for screen readers.
|
||||
- Drawing primitives (`drawWaveform` / `drawSpectrum` / `drawLevel`)
|
||||
are individually exported as `@internal` for test coverage.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, AudioVisualizer } from "@suaveplan/react-audio";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<AudioVisualizer />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, AudioVisualizer } from "@suaveplan/react-audio";
|
||||
|
||||
export function MultiVisualizer() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<AudioVisualizer mode="waveform" />
|
||||
<AudioVisualizer mode="spectrum" busName="auxA" />
|
||||
<AudioVisualizer
|
||||
mode="level"
|
||||
color="#0f0"
|
||||
backgroundColor="#000"
|
||||
width={128}
|
||||
height={32}
|
||||
sampleRateHz={120}
|
||||
/>
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------------- | ------------------- | ----------- | ---------------------------------------------- |
|
||||
| `className` | `string \| undef` | — | Forwarded to the `<canvas>`. |
|
||||
| `width` | `number \| undef` | `320` | Canvas pixel width. |
|
||||
| `height` | `number \| undef` | `80` | Canvas pixel height. |
|
||||
| `mode` | `VisualizerMode` | `"waveform"`| One of the three render modes. |
|
||||
| `busName` | `string \| undef` | `"master"` | Which bus to read. |
|
||||
| `sampleRateHz` | `number \| undef` | `60` | Render tick rate. |
|
||||
| `color` | `string \| undef` | `"#3aa"` | Foreground colour. |
|
||||
| `backgroundColor` | `string \| undef` | `"#111"` | Canvas background colour. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The effect resolves the canvas context once on mount via
|
||||
`canvas.getContext("2d")` and gracefully short-circuits when the
|
||||
context cannot be acquired (test environments without canvas support,
|
||||
or production browsers under aggressive accessibility settings). When
|
||||
the bus has no publisher the visualiser still ticks the canvas to
|
||||
keep the background paint fresh. Spectrum mode uses a naive average
|
||||
of `Math.abs` over a downsampled window rather than a real FFT — this
|
||||
is deliberate so the component has zero non-React runtime dependencies
|
||||
and remains universally portable.
|
||||
@@ -0,0 +1,77 @@
|
||||
# `<BasicPlayer>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<BasicPlayer>` is the minimal media-player surface for an
|
||||
`AudioPlayerPlugin`. It is implemented as a thin composition over
|
||||
`<AudioPlayer>` with the loop toggle and playback-rate input disabled,
|
||||
leaving only the essential controls: play / pause / stop / seek /
|
||||
volume. Use this when you want a clean, distraction-free player and
|
||||
don't need the advanced playback adjustments the full player exposes.
|
||||
|
||||
Because it is a strict composition, every behavioural guarantee of
|
||||
`<AudioPlayer>` — cancel-on-unmount, error surfacing, state polling —
|
||||
applies here unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- Forwards every relevant prop to `<AudioPlayer>`.
|
||||
- Hides the loop toggle and playback rate input.
|
||||
- Adds the `.rax-basic-player` class (or merges it with the supplied
|
||||
`className`) so theming targets are stable across the two surfaces.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
import { AudioProvider, BasicPlayer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new AudioPlayerPlugin({});
|
||||
|
||||
export function Card() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<BasicPlayer plugin={plugin} src="https://cdn/short.wav" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
import { AudioProvider, BasicPlayer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new AudioPlayerPlugin({});
|
||||
|
||||
export function MutedCard() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<BasicPlayer plugin={plugin} className="muted-row" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------- | -------------------------- | ------- | ------------------------------------------- |
|
||||
| `plugin` | `AudioPlayerPlugin` | — | Required. |
|
||||
| `src` | `string \| undef` | — | Auto-load this URL on mount. |
|
||||
| `className` | `string \| undef` | — | Forwarded; merged with `rax-basic-player`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `showLoop` / `showPlaybackRate` props on the underlying
|
||||
`<AudioPlayer>` are fixed at `false`, which makes the BasicPlayer's
|
||||
prop signature smaller than the full player's. There is no separate
|
||||
state, no separate effect — every behavioural guarantee of the full
|
||||
player applies. Pass-through is deliberately verbose (one prop at a
|
||||
time) so future additions to `<AudioPlayer>` do not silently widen
|
||||
`<BasicPlayer>`'s surface; new props must be added explicitly.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `<GLaDOSControls>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<GLaDOSControls>` is the composed UI for the `GLaDOSPlugin` voice
|
||||
transform from `@suaveplan/audio-fx-plugin-glados`. The plugin
|
||||
defines a six-stage voice pipeline (pitch shift, formant shift,
|
||||
robotic intensity, distortion, low-pass cutoff, reverb amount) plus
|
||||
six built-in presets covering the GLaDOS, Wheatley, Cave Johnson, and
|
||||
generic robotic voices. This component surfaces all six stages as
|
||||
range inputs and the preset list as a `<select>`.
|
||||
|
||||
The component is intentionally a thin shell over the plugin: every
|
||||
edit calls the plugin's named setter (`setPitchShift`,
|
||||
`setFormantShift`, etc.), every preset switch calls
|
||||
`applyGladosPreset`. There is no React-side cache that could drift
|
||||
from the plugin's authoritative config.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stage sliders with plugin-defined ranges.
|
||||
- Preset `<select>` covering all six built-ins.
|
||||
- Errors from stage setters (out-of-range values) surfaced via
|
||||
`role="alert"`; cleared when the next successful edit or preset
|
||||
change lands.
|
||||
- Live readout next to every slider.
|
||||
- `data-stage` attributes on every stage for theming hooks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
|
||||
import { AudioProvider, GLaDOSControls } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new GLaDOSPlugin();
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<GLaDOSControls plugin={plugin} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
|
||||
import { AudioProvider, GLaDOSControls } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new GLaDOSPlugin({
|
||||
preset: "cave-johnson",
|
||||
config: { reverbAmount: 0.8 },
|
||||
});
|
||||
|
||||
export function CaveJohnsonVoice() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<GLaDOSControls plugin={plugin} className="cj-voice" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------- | --------------- | ------- | ------------------------------------------- |
|
||||
| `plugin` | `GLaDOSPlugin` | — | Required. |
|
||||
| `className` | `string \| undef`| — | Forwarded to the outer `<section>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The stage table is declared once at module scope so React's diff
|
||||
sees stable identities across renders. Each stage carries its own
|
||||
`min` / `max` / `step` and the plugin setter to invoke; on every
|
||||
change we parse the input value, dispatch to the setter inside a
|
||||
try/catch, and re-read the plugin's config snapshot. Errors are
|
||||
captured as plain strings to keep the error surface portable across
|
||||
runtimes (no SuaveplanError dependency at this layer). Preset
|
||||
switches always clear the prior error because the new preset is a
|
||||
known-good config.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `parameter-keyboard`
|
||||
|
||||
Internal helper consumed by `<PluginParameterKnob>` and
|
||||
`<PluginParameterSlider>` to translate a `KeyboardEvent` into a clamped
|
||||
next parameter value. The helper is a pure function — no React, no DOM
|
||||
— so it is fully unit-testable without rendering a tree.
|
||||
|
||||
## Purpose
|
||||
|
||||
The `<PluginSlot>` accessibility requirement mandates an identical
|
||||
keyboard contract on every parameter control: arrow keys for fine /
|
||||
coarse adjustments, `PageUp`/`PageDown` for ×10 coarse, `Home`/`End` for
|
||||
range endpoints, `Enter` for type-edit, plus `Shift` ×10 and `Alt` ÷10
|
||||
modifiers. Reimplementing this contract in two component files would
|
||||
double the surface area subject to drift. The shared helper guarantees
|
||||
both primitives interpret the keymap identically.
|
||||
|
||||
## Features
|
||||
|
||||
- 1 % step base ×1, ×10, ×100 binding factors (from `PARAMETER_KEY_BINDINGS`).
|
||||
- `Shift` multiplier ×10, `Alt` multiplier ÷10.
|
||||
- Clamping to `[min, max]` on every increment, decrement, and `Home`/`End` jump.
|
||||
- Returns the matching `KeyBinding` so the caller can detect the special
|
||||
`Enter` action and open a type-edit affordance.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveParameterKeyboardChange } from "./parameter-keyboard.js";
|
||||
|
||||
const result = resolveParameterKeyboardChange(
|
||||
{ key: "ArrowRight", shiftKey: false, altKey: false },
|
||||
0,
|
||||
-24,
|
||||
24
|
||||
);
|
||||
// result.handled === true; result.next === 0.48 (1 % of 48)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Compose with the React keyboard handler in <PluginParameterKnob>:
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const out = resolveParameterKeyboardChange(event, value, min, max);
|
||||
if (!out.handled) return;
|
||||
event.preventDefault();
|
||||
if (out.binding?.action === "enter-value") {
|
||||
setEditing(true);
|
||||
return;
|
||||
}
|
||||
onChange(out.next);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveParameterKeyboardChange(event, current, min, max) → ParameterKeyboardResult`
|
||||
- `event` — `{ key, shiftKey, altKey }`; matches the shape of `KeyboardEvent`.
|
||||
- `current` — current numeric value.
|
||||
- `min` / `max` — declared parameter range.
|
||||
- `ParameterKeyboardResult` — `{ handled, next, binding }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The function iterates `PARAMETER_KEY_BINDINGS` in declaration order.
|
||||
Adding new bindings is purely declarative — the helper picks them up
|
||||
for free.
|
||||
- `Home`/`End` deliberately ignore the modifier multipliers; they are
|
||||
endpoint jumps, not stepped moves.
|
||||
- The function never throws; an unrecognised key returns
|
||||
`{ handled: false, next: current, binding: undefined }` so the caller
|
||||
can fall through to other listeners.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `parameter-unit`
|
||||
|
||||
Internal helper that converts the controlled `ParameterUnit` enum
|
||||
exposed by `@suaveplan/audio-fx-plugin-base` (`"db"`, `"hz"`, `"ms"`,
|
||||
`"percent"`, …) into the display string the `formatAriaValueText` a11y
|
||||
helper expects (`"dB"`, `"Hz"`, …). Centralising the mapping in a
|
||||
single helper guarantees every parameter control under `<PluginSlot>`
|
||||
formats `aria-valuetext` consistently regardless of which primitive
|
||||
(`<PluginParameterKnob>`, `<PluginParameterSlider>`) renders it.
|
||||
|
||||
## Purpose
|
||||
|
||||
The audio-plugin contract uses a small controlled lowercase enum so
|
||||
plugin authors cannot drift into ad-hoc spellings. The a11y formatter
|
||||
prefers the human-display capitalisation (dB, Hz, kHz, BPM). The
|
||||
adapter function bridges the two without forcing either side to bend.
|
||||
|
||||
## Features
|
||||
|
||||
- Total function over the `ParameterUnit` enum (no missing branches).
|
||||
- Returns `""` for `"enum"` parameters since their values are not
|
||||
numeric ranges.
|
||||
- Safe on `undefined` — returns `""` so the formatter falls through to
|
||||
its default branch and renders the bare numeric value.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { mapParameterUnitForAria } from "./parameter-unit.js";
|
||||
import { formatAriaValueText } from "../../a11y/aria-value-text/aria-value-text.js";
|
||||
|
||||
const aria = formatAriaValueText(
|
||||
plugin.getParameter("frequency"),
|
||||
mapParameterUnitForAria(parameter.unit),
|
||||
parameter.min,
|
||||
parameter.max
|
||||
);
|
||||
// "880 Hz" for { unit: 'hz', value: 880 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Inside <PluginParameterKnob>, render aria-valuetext from the mapped unit:
|
||||
const ariaUnit = mapParameterUnitForAria(parameter.unit);
|
||||
const valuetext = formatAriaValueText(value, ariaUnit, min, max, {
|
||||
signedDb: true,
|
||||
});
|
||||
return <div role="slider" aria-valuetext={valuetext} ... />;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapParameterUnitForAria(unit: ParameterUnit | undefined): AriaValueUnit`
|
||||
— pure function; total over the enum; defaults to `""`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The function intentionally returns a `string` (`AriaValueUnit` is a
|
||||
string union widened by `| string`), so adding a new audio-side
|
||||
`ParameterUnit` value will surface as a TypeScript exhaustiveness
|
||||
warning on the `switch` in this file only — the formatter does not
|
||||
need to change.
|
||||
- No allocations — the function returns string literals.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `<MasterMeter>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MasterMeter>` is a RAF-sampled master-output level meter. It
|
||||
subscribes to a named sidechain bus through the foundation `useBus`
|
||||
hook (`"master"` by default) and continuously reads the bus's
|
||||
block-aligned PCM into a scratch buffer at the configured interval,
|
||||
deriving per-channel peak and RMS levels in dB and a slow-decaying
|
||||
peak-hold envelope. When no publisher exists for the bus, every
|
||||
channel shows silence (`-∞` dB) so consumers always see something.
|
||||
|
||||
The meter is intentionally side-effect-free with respect to the audio
|
||||
graph: it only reads. Nothing inside `<MasterMeter>` ever writes to the
|
||||
bus or mutates engine state, which keeps it safe to drop into any
|
||||
layout that already has a publisher upstream.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to any named bus via `useBus(busName)`.
|
||||
- Per-channel peak + RMS in dB, plus a peak-hold envelope.
|
||||
- Configurable channel count, sample-tick rate, and hold time.
|
||||
- Reads up to `Math.min(meterChannels, busChannels)` per tick so
|
||||
publisher-wider and meter-wider mismatches do not blow up.
|
||||
- `role="meter"` + `aria-label` for screen readers.
|
||||
- `data-bus` and `data-channel` attributes for theming hooks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, MasterMeter } from "@suaveplan/react-audio";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<MasterMeter />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, MasterMeter } from "@suaveplan/react-audio";
|
||||
|
||||
export function AuxMeter() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<MasterMeter
|
||||
busName="auxA"
|
||||
channels={1}
|
||||
sampleRateHz={120}
|
||||
peakHoldMs={2000}
|
||||
className="aux-meter"
|
||||
/>
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| -------------- | ------------------- | ----------- | ------------------------------------------------------ |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<div>`. |
|
||||
| `busName` | `string \| undef` | `"master"` | Name of the bus to subscribe to. |
|
||||
| `channels` | `number \| undef` | `2` | Number of channel rows to render. |
|
||||
| `sampleRateHz` | `number \| undef` | `60` | UI sample tick rate. Clamped to `>= 1`. |
|
||||
| `peakHoldMs` | `number \| undef` | `1000` | Peak-hold decay time in milliseconds. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component allocates one persistent scratch `Float32Array[]` per
|
||||
mount sized to `channelCount × 128` so the per-tick read does not
|
||||
allocate. Bus channel mismatches are handled by reading only the
|
||||
narrower side: if the publisher is wider than the meter, only the
|
||||
leading channels are read; if the meter is wider than the publisher,
|
||||
the trailing channels keep their zero-fill from the scratch buffer's
|
||||
last write (which corresponds to silence on next dB conversion). The
|
||||
peak-hold envelope uses one mutable record per channel keeping the
|
||||
current hold value and its expiry timestamp; the value is replaced
|
||||
either when a louder peak arrives or when the hold window expires,
|
||||
which gives the visually slow decay typical of professional meters.
|
||||
@@ -0,0 +1,100 @@
|
||||
# `<MicrophoneControls>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MicrophoneControls>` is the composed UI for
|
||||
`MicrophoneSimulationPlugin` from
|
||||
`@suaveplan/audio-fx-plugin-microphone`. The plugin itself declares
|
||||
its UI surface via `getUISections()`: a list of grouped controls
|
||||
(frequency, resonance, distortion, noise, dynamics, gain, character,
|
||||
wow/flutter) with their numeric ranges and step sizes. The component
|
||||
mirrors that data-driven structure, rendering one `<fieldset>` per
|
||||
section and one `<input type="range">` per control. Writes go through
|
||||
`plugin.setMicrophoneConfig` with the minimal partial-update.
|
||||
|
||||
Because the UI structure is driven entirely by the plugin's metadata,
|
||||
adding a new control on the plugin automatically shows up here — no
|
||||
React code change required.
|
||||
|
||||
## Features
|
||||
|
||||
- Data-driven layout from `plugin.getUISections()`.
|
||||
- One range input per declared control, with `min` / `max` / `step`
|
||||
taken from the plugin's metadata.
|
||||
- Live readout shown alongside every slider via `aria-live="polite"`.
|
||||
- Non-numeric input ignored to keep the plugin config schema-valid.
|
||||
- `aria-label="Microphone controls"` on the outer section and
|
||||
per-control labels in the form `"<group> <control>"`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
|
||||
import {
|
||||
AudioProvider,
|
||||
MicrophoneControls,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new MicrophoneSimulationPlugin();
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<MicrophoneControls plugin={plugin} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
|
||||
import {
|
||||
AudioProvider,
|
||||
MicrophoneControls,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new MicrophoneSimulationPlugin({
|
||||
config: {
|
||||
distortion: { drive: 0.4, mix: 0.5 },
|
||||
wowFlutter: {
|
||||
wowRateHz: 0.5,
|
||||
wowDepth: 0.05,
|
||||
flutterRateHz: 8,
|
||||
flutterDepth: 0.02,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function StudioMic() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<MicrophoneControls plugin={plugin} className="studio-mic" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------- | ----------------------------- | ------- | ------------------------------------------- |
|
||||
| `plugin` | `MicrophoneSimulationPlugin` | — | Required. |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component stores a single config snapshot in React state and
|
||||
refreshes it on every successful write. Section / control ids are
|
||||
exposed via `data-section` / per-control keys so consumers can target
|
||||
any cell with CSS without inspecting the plugin's metadata. The
|
||||
`updateField` helper constructs a minimal partial-update of the form
|
||||
`{ [group]: { ...prev[group], [field]: newValue } }`, which keeps
|
||||
unrelated group fields stable across the `setMicrophoneConfig` call;
|
||||
the plugin's own merge logic then folds the partial into its live
|
||||
config tree.
|
||||
@@ -0,0 +1,112 @@
|
||||
# `components/midi-controller-setup`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MidiControllerSetup>` is the Phase-3 learn-mode panel for the
|
||||
Suaveplan React audio stack. It renders the connected controllers
|
||||
from a `MidiControllerRegistry` (supplied either via the
|
||||
`controllers` prop or via the surrounding
|
||||
`MidiControllerRegistryContext`), pairs them with a target picker
|
||||
built from the supplied `plugins`, and arranges the captured
|
||||
`(channel, controller)` pair against the parameter chosen by the
|
||||
user. Each established binding is materialised through
|
||||
`bindParameter` from `@suaveplan/audio-controllers` and persisted via
|
||||
the same `PresetStore` the `<PresetManager>` consumes, so the bindings
|
||||
re-bind on next mount.
|
||||
|
||||
The component is intentionally lean: it does not own the controller
|
||||
hardware nor the MIDI access — it composes the live
|
||||
`MidiControllerPlugin` instance the application has already
|
||||
constructed at startup. This keeps the lifecycle ownership in one
|
||||
place and matches the rest of the Phase-2 component family.
|
||||
|
||||
## Features
|
||||
|
||||
- Controller / plugin / parameter picker rendered via accessible
|
||||
`<select>` elements with `aria-label`s on each control.
|
||||
- `Learn` button enters learn-mode through `useMidiController`'s
|
||||
`learn(target)` method.
|
||||
- Captured CC is bound through `bindParameter` with the optional
|
||||
`smoothingMs` forwarded to the binding helper.
|
||||
- Active bindings are rendered as an `<ul>` with `Unbind` buttons.
|
||||
`Unbind` calls the unsubscribe function returned by `bindParameter`.
|
||||
- Bindings are persisted under `presetKey` (default
|
||||
`midi-controller-setup`) through the provider's `presetStore`.
|
||||
- On mount, persisted bindings are rehydrated and re-bound.
|
||||
- Live `state` indicator next to the Learn button mirrors the
|
||||
controller FSM (`disconnected → connecting → connected → error`).
|
||||
- Renders an `data-empty="true"` empty state when no controllers are
|
||||
registered.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import {
|
||||
MidiControllerRegistryContext,
|
||||
MidiControllerSetup,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
function MidiPanel({ controllers, plugins }) {
|
||||
return (
|
||||
<MidiControllerRegistryContext.Provider value={controllers}>
|
||||
<MidiControllerSetup plugins={plugins} />
|
||||
</MidiControllerRegistryContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { MidiControllerSetup } from "@suaveplan/react-audio";
|
||||
|
||||
function AdvancedMidi({ controllers, plugins }) {
|
||||
return (
|
||||
<MidiControllerSetup
|
||||
plugins={plugins}
|
||||
controllers={controllers}
|
||||
smoothingMs={25}
|
||||
presetKey="studio-bindings"
|
||||
className="studio-midi"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Persisted bindings are stored under the `"chain"` kind in the
|
||||
`PresetStore` so any store implementation that already serves chain
|
||||
snapshots transparently serves binding lists too. The payload shape
|
||||
is `{ bindings: MidiBinding[] }`; consumers can inspect the same
|
||||
key from a sibling tool without involving the React surface.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MidiControllerSetupProps.plugins` — `ReadonlyArray<AudioFxPlugin>`
|
||||
whose `parameterIndex` entries become the parameter picker.
|
||||
- `MidiControllerSetupProps.controllers` — optional explicit
|
||||
`MidiControllerRegistry`; takes precedence over context.
|
||||
- `MidiControllerSetupProps.smoothingMs` — forwarded to
|
||||
`bindParameter`. Defaults to `0`.
|
||||
- `MidiControllerSetupProps.presetKey` — preset-store key for the
|
||||
persisted binding list. Defaults to `midi-controller-setup`.
|
||||
- `MidiControllerSetupProps.className` — CSS class forwarded to the
|
||||
outer `<section>`.
|
||||
- `MidiBinding` — `{ controllerId, pluginId, paramId, source,
|
||||
smoothingMs? }`. The persisted payload shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component composes `useMidiController(controllerId, registry)`
|
||||
to source the live controller and its FSM state, then forwards the
|
||||
captured CC into `bindParameter`. Because `bindParameter` returns an
|
||||
`UnsubscribeFn`, the component stores those handles alongside the
|
||||
binding metadata and tears them all down on unmount via a `useRef`
|
||||
snapshot to avoid the classic "stale-effect-cleanup" pitfall where
|
||||
the cleanup closure captures an out-of-date `active` list. Errors
|
||||
from either learn capture or `bindParameter` are surfaced through a
|
||||
non-blocking inline `role="alert"` paragraph rather than thrown so
|
||||
the panel keeps rendering when one binding fails.
|
||||
94
docs/ui/react/react-audio/src/components/mixer/mixer.md
Normal file
94
docs/ui/react/react-audio/src/components/mixer/mixer.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `<Mixer>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Mixer>` is the per-channel strip view for a live `MixerPlugin`
|
||||
instance from `@suaveplan/audio-fx-plugin-mixer`. It renders one row
|
||||
per channel (gain + pan slider + live VU readout) plus an optional
|
||||
master gain row. Channel writes are routed through the plugin's
|
||||
`setChannelGain` / `setChannelPan` methods; the master row binds the
|
||||
plugin's `"masterGain"` parameter via the foundation `useParameter`
|
||||
hook so external automation surfaces (curves, presets, recall) keep
|
||||
working transparently.
|
||||
|
||||
The Mixer composition exists to make the audio plugin the source of
|
||||
truth — there is no React-side store of channel state. Every commit
|
||||
samples the plugin via `getChannels()` so swapping the plugin (e.g.
|
||||
via a `<PluginLoader>`) immediately updates the visible strips.
|
||||
|
||||
## Features
|
||||
|
||||
- One row per channel returned by `plugin.getChannels()`.
|
||||
- Per-channel gain (range `0..4`) and pan (range `-1..1`) sliders.
|
||||
- Master gain row bound to the plugin's `"masterGain"` parameter.
|
||||
- Per-channel VU readout from `plugin.getVuLevels()`, refreshed at
|
||||
`vuSampleRateHz` (default 30, clamped `>= 1`).
|
||||
- `aria-label` on every input plus a `role="list"` strip container.
|
||||
- `hideMaster` suppresses the master row for embedded usage.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
import { AudioProvider, Mixer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new MixerPlugin({
|
||||
channels: [{ id: "drums" }, { id: "bass" }, { id: "vox" }],
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<Mixer plugin={plugin} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
import { AudioProvider, Mixer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "guitar", initialGain: 0.8, initialPan: -0.3 },
|
||||
{ id: "bass", initialGain: 0.7 },
|
||||
],
|
||||
initialMasterGain: 1.2,
|
||||
});
|
||||
|
||||
export function Workstation() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<Mixer plugin={plugin} vuSampleRateHz={60} hideMaster />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------------- | ------------------- | ------- | ------------------------------------------------ |
|
||||
| `plugin` | `MixerPlugin` | — | Required. The live mixer plugin instance. |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
|
||||
| `hideMaster` | `boolean \| undef` | `false` | Hides the master row when `true`. |
|
||||
| `vuSampleRateHz` | `number \| undef` | `30` | UI tick rate for VU + channel snapshots. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not stash channel state in React: every interval
|
||||
tick re-reads `plugin.getChannels()` and `plugin.getVuLevels()` into
|
||||
the local snapshot used for rendering. Channel mutations go through
|
||||
the plugin's mutators (`setChannelGain` / `setChannelPan`) and
|
||||
immediately re-read so the next render reflects the change without
|
||||
waiting for the interval. Master gain is treated as a normal plugin
|
||||
parameter so curves and presets work transparently. The VU snapshot
|
||||
sample rate is clamped to `>= 1 Hz` to defeat divide-by-zero on
|
||||
`setInterval` and survives `vuSampleRateHz={0}` without crashing.
|
||||
@@ -0,0 +1,99 @@
|
||||
# `<PluginChainView>`
|
||||
|
||||
Reorderable, accessible view of a `PluginChain`. Renders one
|
||||
`<PluginSlot>` per chain entry, supports HTML5 drag-and-drop with a
|
||||
full keyboard alternative, exposes an "Add FX" picker filtered against
|
||||
the chain's effective channel count, and surfaces any rejection from
|
||||
`chain.add()` / `chain.move()` as a non-blocking toast carrying the
|
||||
underlying `AudioFxError`'s `code`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<PluginChainView>` is the workhorse list component for the audio
|
||||
workspace. It owns no plugin instances directly — every plugin is
|
||||
created by the provider's `PluginRegistry` and added to the chain via
|
||||
the Phase-1 `usePluginChain` hook. The view re-renders on every
|
||||
`plugin-added` / `plugin-removed` / `plugin-moved` / `plugin-replaced`
|
||||
/ `bypass-changed` event because `usePluginChain` subscribes to all
|
||||
five.
|
||||
|
||||
## Features
|
||||
|
||||
- HTML5 DnD: `draggable` slots; `onDragStart` carries the source
|
||||
index; `onDrop` reads the index and dispatches `chain.move()`.
|
||||
- Accepts external drops carrying a `application/x-audio-fx-type`
|
||||
payload (e.g. from a separate plugin browser) and inserts at the
|
||||
target index.
|
||||
- Keyboard reorder: `Space` picks up the focused slot, `↑`/`↓` move
|
||||
the hover marker, `Enter` drops, `Escape` cancels.
|
||||
- IO-aware "Add FX" picker — `PluginRegistry.list()` filtered against
|
||||
the chain's current effective output channel count via the plugin's
|
||||
declared `io.inputs[0].count`.
|
||||
- Toast surfaces with `role="status"`, `data-code` attribute, and a
|
||||
dismiss control. Toast clears when the user clicks Dismiss.
|
||||
- `data-track-id` on the wrapper, `data-picked` and
|
||||
`data-hover-target` on slots, `aria-grabbed` on the active slot.
|
||||
- CSS-class API only (`rax-chain`, `rax-chain__header`,
|
||||
`rax-chain__list`, `rax-chain__item`, `rax-chain__picker`,
|
||||
`rax-chain__toast`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { PluginChainView } from "@suaveplan/react-audio/components/plugin-chain-view";
|
||||
|
||||
function TrackPanel({ trackId }) {
|
||||
return <PluginChainView trackId={trackId} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
// Read-only chain preview with the picker hidden, themed wrapper.
|
||||
function ChainPreview({ trackId }) {
|
||||
return (
|
||||
<PluginChainView
|
||||
trackId={trackId}
|
||||
hideAddPicker
|
||||
className="ring-1 ring-zinc-700 rounded-md p-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
- `trackId: string` — passed to `usePluginChain` to resolve the chain.
|
||||
- `className?: string` — CSS class appended to the wrapper.
|
||||
- `hideAddPicker?: boolean` — when `true`, the "Add FX" picker button
|
||||
and menu are not rendered.
|
||||
|
||||
### Events surfaced
|
||||
|
||||
- A rejection from `chain.add()` or `chain.move()` produces a toast
|
||||
with the `AudioFxError.code` (e.g.
|
||||
`AUDIO_FX.CHAIN_IO_INCOMPATIBLE`).
|
||||
- Successful insertion / reorder / removal updates the list via the
|
||||
Phase-1 chain-event subscription.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The picker probes each registered type by calling
|
||||
`registry.create()` once, inspecting `io.inputs[0].count`, then
|
||||
disposing the probe instance. The cost is amortised across the
|
||||
picker open/close lifecycle.
|
||||
- Keyboard reorder uses local state for the picked-up index and a
|
||||
separate "hover" index so the user can see the prospective drop
|
||||
target before committing.
|
||||
- The HTML5 DnD handler clears its drag data on every render so a
|
||||
cancelled drag does not leak into the next interaction.
|
||||
- The toast is a single most-recent error — multiple errors collapse
|
||||
into one. This keeps the surface deterministic without depending on
|
||||
a toast queue library.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `<PluginLoader>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<PluginLoader>` is the dynamic plugin-registration UI over the
|
||||
provider's `PluginRegistry`. It lists every currently-registered
|
||||
`pluginType`, lets the user pick one and click "Create" to
|
||||
instantiate it via `registry.create(...)`, and exposes a small
|
||||
registration form for adding new factories at runtime. It is the
|
||||
canonical surface for development panels and demo applications that
|
||||
want to swap plugins in and out without restarting the provider.
|
||||
|
||||
In production, callers typically pre-register their factories via the
|
||||
provider's `options.plugins` array — `<PluginLoader>` then acts as a
|
||||
purely informational dashboard showing what's available, with the
|
||||
"Create" button delegating to the registry's standard factory
|
||||
contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Lists every registered `pluginType` from `PluginRegistry.list()`.
|
||||
- `<select>` picker bound to a local "selected" state.
|
||||
- "Create" button invokes `registry.create(...)`, catches and
|
||||
surfaces the registry's `AUDIO_FX.PLUGIN_TYPE_NOT_REGISTERED`
|
||||
error via a `role="alert"` element.
|
||||
- Optional `onCreated` callback receives the constructed plugin so
|
||||
parent components can wire it into a chain.
|
||||
- Inline registration form for adding placeholder factories at
|
||||
runtime (mainly for dev surfaces).
|
||||
- Empty-state handling when no plugins are registered.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, PluginLoader } from "@suaveplan/react-audio";
|
||||
|
||||
export function DevPanel() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<PluginLoader />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AudioProvider,
|
||||
PluginLoader,
|
||||
usePluginChain,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
export function WiredLoader() {
|
||||
const chain = usePluginChain("master");
|
||||
return (
|
||||
<AudioProvider>
|
||||
<PluginLoader
|
||||
onCreated={(plugin) => chain.add(plugin)}
|
||||
className="dev-loader"
|
||||
/>
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ------------ | ------------------------------------------------- | ------- | ------------------------------------------- |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
|
||||
| `onCreated` | `(plugin, pluginType) => void` | — | Called when "Create" succeeds. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is the source of truth: every render re-reads
|
||||
`registry.list()` so newly-registered factories show up immediately
|
||||
without needing a snapshot store. The component keeps a local
|
||||
`version` counter that is incremented after every registration so
|
||||
React schedules a re-render — `list()` itself is a method, not a
|
||||
reactive primitive. The registration form intentionally writes a
|
||||
*placeholder* factory that throws when invoked; this prevents the
|
||||
loader from accidentally being used in production to register
|
||||
half-real factories. Consumers wanting real registration should call
|
||||
`registry.register(...)` directly from a parent component.
|
||||
@@ -0,0 +1,107 @@
|
||||
# `<PluginParameterKnob>`
|
||||
|
||||
Rotary-knob primitive for a single `AudioFxPlugin` parameter. The knob
|
||||
implements the full keyboard contract mandated by the
|
||||
`<PluginSlot>` accessibility requirement, exposes `role="slider"` with
|
||||
ARIA range metadata derived from the parameter's `min`/`max`/`unit`
|
||||
declaration, and supports an `Enter`-to-edit type-in affordance.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `<PluginParameterKnob>` is the rotary half of the generic
|
||||
parameter-control surface. Specialised plugin UIs (an EQ panel, a
|
||||
compressor panel) often wrap the knob with their own visual chrome
|
||||
while reusing the keyboard, ARIA, and value-management logic. The
|
||||
knob is therefore deliberately presentational: the entire keyboard
|
||||
keymap, value clamping, and typed-edit flow live here, while the
|
||||
caller supplies the plugin and parameter declaration.
|
||||
|
||||
The knob subscribes to the parameter via the Phase-1
|
||||
`useParameter` hook, so every change is reflected at the provider's
|
||||
declared `sampleRateHz` — by default 60 Hz, matching one tick per
|
||||
animation frame.
|
||||
|
||||
## Features
|
||||
|
||||
- `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`,
|
||||
`aria-valuetext` (unit-aware via `formatAriaValueText`).
|
||||
- `←`/`→` fine step (1 % of range), `↑`/`↓` coarse step (10 %),
|
||||
`PageUp`/`PageDown` ×100 page step, `Home`/`End` jump to range
|
||||
endpoints.
|
||||
- `Shift` ×10, `Alt` ÷10 modifier multipliers.
|
||||
- `Enter` opens a numeric type-edit input; `Enter` commits, `Escape`
|
||||
reverts, blur commits.
|
||||
- `data-parameter-id` attribute on the wrapper so plugin authors can
|
||||
target specific knobs from a stylesheet.
|
||||
- Pure CSS-class API (`rax-knob`, `rax-knob__dial`, `rax-knob__readout`,
|
||||
`rax-knob__input`) — no styling assumptions baked in.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { PluginParameterKnob } from "@suaveplan/react-audio/components/plugin-parameter-knob";
|
||||
|
||||
function GainKnob({ plugin }) {
|
||||
const param = plugin.parameterIndex.get("gain")!;
|
||||
return <PluginParameterKnob plugin={plugin} parameter={param} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
// Iterate every parameter on a plugin, rendering each with a custom label
|
||||
// override and theming via the className API.
|
||||
function PluginPanel({ plugin }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{plugin.parameters.map((p) => (
|
||||
<PluginParameterKnob
|
||||
key={p.id}
|
||||
plugin={plugin}
|
||||
parameter={p}
|
||||
label={p.name.toUpperCase()}
|
||||
className="ring-1 ring-zinc-700 rounded-md p-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
- `plugin: AudioFxPlugin` — the plugin instance whose parameter is bound.
|
||||
- `parameter: PluginParameter` — the declaration to render. The parameter
|
||||
MUST be present in `plugin.parameterIndex`; otherwise the underlying
|
||||
`useParameter` hook throws `REACT_AUDIO.UNKNOWN_PARAMETER`.
|
||||
- `label?: string` — optional visible label override; defaults to
|
||||
`parameter.name`.
|
||||
- `className?: string` — optional CSS class appended to the wrapper.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- Each parameter update flows through `param.set(...)`, which delegates
|
||||
to `plugin.setParameter`. Value subscriptions go through the
|
||||
Phase-1 `useParameter` hook.
|
||||
- The typed-edit input is rendered only when the user presses `Enter`
|
||||
on the knob; it auto-focuses, supports `Enter`/`Escape`/blur, and
|
||||
clamps the parsed value to the declared range.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The keyboard handler delegates to the pure `resolveParameterKeyboardChange`
|
||||
helper, which is shared with `<PluginParameterSlider>` to guarantee
|
||||
identical step semantics between the two primitives.
|
||||
- `formatAriaValueText` is called with `signedDb: true` so dB readouts
|
||||
carry the `+` sign — matching the convention used by every other
|
||||
audio tool that ships unit-aware screen-reader text.
|
||||
- No animation libraries are imported; visual rotation should be
|
||||
composed by the consumer via CSS. The component respects
|
||||
`prefers-reduced-motion` by virtue of being CSS-only.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `<PluginParameterSlider>`
|
||||
|
||||
Linear-slider primitive for a single `AudioFxPlugin` parameter.
|
||||
Mirrors `<PluginParameterKnob>`'s a11y contract and exposes a native
|
||||
`<input type="range">` for pointer drag while implementing the
|
||||
mandated keyboard keymap directly so step semantics match the knob.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `<PluginParameterSlider>` is the linear half of the generic
|
||||
parameter-control surface. It is preferred over the knob when the
|
||||
parameter is naturally horizontal — pan, dry/wet mix, threshold,
|
||||
crossfeed — and when consumers want native touch / pointer drag
|
||||
without an extra dependency.
|
||||
|
||||
The slider subscribes to the parameter via the Phase-1
|
||||
`useParameter` hook, so per-block parameter updates flow to the UI at
|
||||
the provider's configured `sampleRateHz`.
|
||||
|
||||
## Features
|
||||
|
||||
- Native `<input type="range">` for pointer & touch drag, with `step`
|
||||
set to `(max - min) / 100` so drag granularity matches the keyboard
|
||||
fine-step.
|
||||
- `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`,
|
||||
`aria-valuetext` (unit-aware via `formatAriaValueText`).
|
||||
- Full keyboard keymap: `←`/`→`, `↑`/`↓`, `PageUp`/`PageDown`,
|
||||
`Home`/`End`, `Shift` ×10, `Alt` ÷10. Identical semantics to the
|
||||
knob.
|
||||
- `orientation` prop (`"horizontal"` | `"vertical"`) reflected on the
|
||||
wrapper as `data-orientation`.
|
||||
- `data-parameter-id` for stylesheet targeting.
|
||||
- CSS-class API only (`rax-slider`, `rax-slider__input`,
|
||||
`rax-slider__label`, `rax-slider__readout`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { PluginParameterSlider } from "@suaveplan/react-audio/components/plugin-parameter-slider";
|
||||
|
||||
function MixSlider({ plugin }) {
|
||||
const param = plugin.parameterIndex.get("mix")!;
|
||||
return <PluginParameterSlider plugin={plugin} parameter={param} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
// Render a vertical pan slider with a themed wrapper.
|
||||
function PanSlider({ plugin }) {
|
||||
const param = plugin.parameterIndex.get("pan")!;
|
||||
return (
|
||||
<PluginParameterSlider
|
||||
plugin={plugin}
|
||||
parameter={param}
|
||||
orientation="vertical"
|
||||
label="PAN"
|
||||
className="ring-1 ring-zinc-700 rounded-md p-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
- `plugin: AudioFxPlugin` — the plugin instance bound to the slider.
|
||||
- `parameter: PluginParameter` — the declaration to render. MUST be
|
||||
present in `plugin.parameterIndex`.
|
||||
- `orientation?: "horizontal" | "vertical"` — visual layout hint;
|
||||
defaults to `"horizontal"`.
|
||||
- `label?: string` — optional visible label override; defaults to
|
||||
`parameter.name`.
|
||||
- `className?: string` — optional CSS class appended to the wrapper.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- Pointer drags fire the native `onChange`, which delegates to
|
||||
`plugin.setParameter`.
|
||||
- Keyboard events bypass the native range step (which would default to
|
||||
1 and produce mis-stepped values for parameters whose range is
|
||||
smaller or larger than 100) and run through the shared
|
||||
`resolveParameterKeyboardChange` resolver so step semantics match
|
||||
the knob exactly.
|
||||
- `Enter` is intentionally a no-op on the slider; consumers expecting
|
||||
a type-edit affordance should use `<PluginParameterKnob>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The native `step` attribute is `(max - min) / 100`, falling back to
|
||||
`0.0001` for degenerate `min === max` declarations. The keyboard
|
||||
handler never reads the native step.
|
||||
- The slider does not animate; pointer & keyboard interactions write
|
||||
the parameter directly and the next provider sample reads it back.
|
||||
- A `<label htmlFor>` link is used so click-to-focus on the label
|
||||
works without any JavaScript wiring.
|
||||
@@ -0,0 +1,99 @@
|
||||
# `<PluginSlot>`
|
||||
|
||||
Generic renderer for any `AudioFxPlugin`. Reads the plugin's
|
||||
declarative `parameters` array and renders each one through the
|
||||
primitive picked by the parameter's unit declaration. A specialised
|
||||
renderer registered through the provider's `slotRenderers` option
|
||||
supersedes the generic slot for the matching plugin id.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<PluginSlot>` is the per-plugin face of `<PluginChainView>`. It
|
||||
turns a plugin's static declaration (metadata + parameter list + IO
|
||||
shape) into a reactive form without the consumer having to wire up
|
||||
each control by hand. The component is intentionally
|
||||
presentational — bypass, advanced-toggle, and parameter writes go
|
||||
through the same hooks any caller would use, so an EQ panel that
|
||||
wants its own visual chrome can replace the slot through the
|
||||
`slotRenderers` map without losing the keyboard / ARIA contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders `plugin.metadata.name`, `version`, and `category` in the slot header.
|
||||
- Iterates `plugin.parameters` in declaration order.
|
||||
- Picks a knob for `unit: "db"` / `unit: "hz"` and a slider for every
|
||||
other unit (including `"percent"`, `"ms"`, `"semitones"`).
|
||||
- Exposes a bypass toggle wired to `chain.bypass(plugin.metadata.id, …)`
|
||||
when a `chain` prop is supplied; the toggle reflects the live
|
||||
bypass state via a `bypass-changed` subscription.
|
||||
- Filters parameters whose `category === "advanced"` behind a toggle
|
||||
that defaults to hidden.
|
||||
- Forwards a CSS-class API (`rax-slot`, `rax-slot__header`,
|
||||
`rax-slot__params`, `rax-slot__bypass`, `rax-slot__advanced-toggle`)
|
||||
and `data-plugin-id` / `data-plugin-category` / `data-bypassed`
|
||||
attributes for stylesheet targeting.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { PluginSlot } from "@suaveplan/react-audio/components/plugin-slot";
|
||||
|
||||
function ChainItem({ plugin, chain }) {
|
||||
return <PluginSlot plugin={plugin} chain={chain} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
// Provide a specialised EQ panel through provider options; the slot
|
||||
// will render <EQPanel/> instead of the generic form for the eq plugin.
|
||||
function App() {
|
||||
return (
|
||||
<AudioProvider
|
||||
options={{
|
||||
slotRenderers: {
|
||||
eq: EQPanel,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PluginSlot plugin={eqPlugin} chain={chain} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
- `plugin: AudioFxPlugin` — the plugin to render.
|
||||
- `chain?: PluginChain` — when supplied, the bypass toggle is rendered
|
||||
and writes via `chain.bypass()`; without a chain there is no
|
||||
bypass surface to talk to.
|
||||
- `className?: string` — CSS class appended to the wrapper.
|
||||
- `hideAdvancedToggle?: boolean` — hide the advanced toggle even when
|
||||
advanced parameters are present.
|
||||
|
||||
### Provider integration
|
||||
|
||||
- `slotRenderers: Record<string, ComponentType<PluginSlotRendererProps>>`
|
||||
on `AudioProviderOptions` maps a plugin id to a specialised
|
||||
renderer. The generic slot consults the map first and bails to the
|
||||
registered component when present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bypass toggle subscribes via `chain.on("bypass-changed", …)` so
|
||||
toggles initiated elsewhere (preset apply, another panel, MIDI in
|
||||
Phase 3) propagate without a polling tick.
|
||||
- The advanced toggle is local state on the slot; it does not survive
|
||||
unmount. Persisting it would require an addition to the provider's
|
||||
UI store, which is deferred to extras.
|
||||
- The unit→control table (`db` / `hz` → knob, else slider) is
|
||||
intentionally minimal. Specialised renderers cover any remaining
|
||||
cases (segmented control for `enum`, log-curve knob, …).
|
||||
@@ -0,0 +1,88 @@
|
||||
# `<PremiumPlayer>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<PremiumPlayer>` is the full-featured media-player surface — a
|
||||
composition of `<AudioPlayer>`, `<AudioVisualizer>`, and
|
||||
`<MasterMeter>` arranged as a single instrument panel. It is intended
|
||||
for hero player surfaces (landing pages, profile pages, demo apps)
|
||||
that want the whole monitoring suite without forcing consumers to
|
||||
wire three components by hand.
|
||||
|
||||
Every behavioural guarantee of the underlying components applies:
|
||||
the player polls plugin state, the visualiser reads block samples
|
||||
from the named bus, and the meter computes peak / RMS / hold envelope
|
||||
on every UI tick. None of the three sub-components share state; they
|
||||
compose by sitting in the same DOM section.
|
||||
|
||||
## Features
|
||||
|
||||
- Bundles `<AudioPlayer>` + `<AudioVisualizer>` + `<MasterMeter>`.
|
||||
- Routes a single `busName` to both the visualiser and the meter so
|
||||
they show the same source.
|
||||
- Forwards `src` to the player for auto-load on mount.
|
||||
- Adds the `.rax-premium-player` class for theming.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
import { AudioProvider, PremiumPlayer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new AudioPlayerPlugin({});
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<PremiumPlayer plugin={plugin} src="https://cdn/hero.wav" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
import { AudioProvider, PremiumPlayer } from "@suaveplan/react-audio";
|
||||
|
||||
const plugin = new AudioPlayerPlugin({});
|
||||
|
||||
export function AuxBusPlayer() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<PremiumPlayer
|
||||
plugin={plugin}
|
||||
src="https://cdn/aux.wav"
|
||||
busName="auxA"
|
||||
className="aux-hero"
|
||||
/>
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------- | ------------------- | ----------- | -------------------------------------------------- |
|
||||
| `plugin` | `AudioPlayerPlugin` | — | Required. |
|
||||
| `src` | `string \| undef` | — | Forwarded to `<AudioPlayer>` for auto-load. |
|
||||
| `busName` | `string \| undef` | `"master"` | Routed to the visualiser and meter. |
|
||||
| `className` | `string \| undef` | — | Forwarded; merged with `rax-premium-player`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The composition only forwards the `busName` to the visualiser + meter
|
||||
when one is supplied — otherwise the sub-components fall back to
|
||||
their own `"master"` defaults, which keeps the API surface honest
|
||||
about what we are choosing for the user. The player sub-component
|
||||
also receives the `src` only when it is defined, so the auto-load
|
||||
effect inside `<AudioPlayer>` doesn't run with `undefined` and trip a
|
||||
guard. There is no separate state in this component; it is pure
|
||||
composition. New monitoring sub-components (e.g. spectrum analysers,
|
||||
oscilloscope) can be added inline without changing the prop signature.
|
||||
@@ -0,0 +1,103 @@
|
||||
# `<PresetManager>`
|
||||
|
||||
`ChainSnapshot` save / load / delete UI layered over the provider's
|
||||
preset store. The save path persists the snapshot through
|
||||
`useChainSnapshot(chain).save(id)`, which validates against the
|
||||
`ChainSnapshotSchema` re-exported from
|
||||
`@suaveplan/audio-fx-plugin-base` and hands the serialised payload
|
||||
to the store. The load path calls `chain.restore()` through the
|
||||
hook; the delete path calls the optional `store.delete()` extension.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Plugin chains have a non-trivial state shape (per-slot plugin id,
|
||||
parameter values, bypass flags) and survive only as long as the
|
||||
provider stays mounted. The `<PresetManager>` gives an end-user a
|
||||
direct affordance to capture that state, name it, restore it later,
|
||||
and surface store / restore errors without crashing the surrounding
|
||||
UI. The component is intentionally chain-scoped — the same
|
||||
`<PresetManager chain={c}>` instance can sit beside any
|
||||
`<PluginChainView>` for the same chain.
|
||||
|
||||
## Features
|
||||
|
||||
- `Save` button — disabled until the name input is non-empty;
|
||||
invokes `snapshot.save(name)`, optimistically updates the local
|
||||
saved-id list, bumps an internal `version` counter so subscribed
|
||||
views re-render.
|
||||
- `Load` button — invokes `snapshot.load(id)`, which restores the
|
||||
chain order, parameters, and bypass states.
|
||||
- `Delete` button — invokes `store.delete("chain", id)` when the
|
||||
store supports the optional extension.
|
||||
- Saved-id list is fetched from `store.list("chain")` if the store
|
||||
supports it; otherwise the list reflects only the saves issued by
|
||||
the live component instance.
|
||||
- Toast surfaces every rejection with `role="status"`, the
|
||||
`AudioFxError` code (e.g.
|
||||
`AUDIO_FX.CHAIN_SLOT_NOT_FOUND`), and a dismiss button.
|
||||
- Renders an explanatory empty state when no preset store is
|
||||
configured on the provider.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { PresetManager } from "@suaveplan/react-audio/components/preset-manager";
|
||||
|
||||
function ChainSidebar({ chain }) {
|
||||
return <PresetManager chain={chain} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
// Wire a custom IndexedDB-backed preset store on the provider so
|
||||
// PresetManager can render a fully reactive saved-id list.
|
||||
function App() {
|
||||
const store = useMemo(() => createIndexedDbPresetStore(), []);
|
||||
return (
|
||||
<AudioProvider options={{ presetStore: store }}>
|
||||
<PresetManager chain={chain} className="rounded-md p-4" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
- `chain: PluginChain` — the chain whose snapshot is persisted.
|
||||
- `className?: string` — CSS class appended to the wrapper.
|
||||
|
||||
### Optional store extension
|
||||
|
||||
```ts
|
||||
export interface PresetStoreWithListing extends PresetStore {
|
||||
list?(kind: "plugin" | "chain"): Promise<ReadonlyArray<string>>;
|
||||
delete?(kind: "plugin" | "chain", id: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
When the configured `presetStore` implements `list`, the saved-id
|
||||
list is fetched on mount and after every save / delete; when it
|
||||
implements `delete`, the Delete button is functional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The save path runs `snapshot.save(id)` which itself wraps store
|
||||
rejections in `REACT_AUDIO.PRESET_STORE_FAILED`; the component
|
||||
re-surfaces the rejection in the toast.
|
||||
- The load path goes through `chain.restore(snapshot, registry)`,
|
||||
which is where `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` is thrown for
|
||||
missing plugin types — exactly the error the spec scenario asserts
|
||||
the toast carries.
|
||||
- The version counter exists so any external `useChainSnapshot(chain)`
|
||||
consumer that calls `snapshot.load(...)` after a delete re-runs
|
||||
through React's effect graph rather than caching stale ids.
|
||||
- The component does not attempt to round-trip the preset on save;
|
||||
the chain's own `chain.snapshot()` is the source of truth.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `<TrackList>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<TrackList>` is a composed-plugin UI that renders every track in the
|
||||
provider's `TimelineEngine`. It is the canonical multi-track surface
|
||||
for the React layer: each row is delegated to `<TrackRow>` so the list
|
||||
view stays small and theming concerns are isolated. The component is
|
||||
event-driven — it subscribes to the engine's typed `trackChange` event
|
||||
and updates its snapshot whenever the engine fires.
|
||||
|
||||
It is intended to drop into any layout where you want a live track
|
||||
list with no additional wiring. The "Add track" button is included as
|
||||
a convenience and can be hidden when the parent surface owns track
|
||||
creation through some richer flow.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `TimelineEngine.on("trackChange")` and re-renders with
|
||||
a fresh snapshot whenever tracks are added, removed, or updated.
|
||||
- Renders an explicit "No tracks." placeholder when the engine has no
|
||||
tracks so users always see something.
|
||||
- "Add track" button delegates to `timeline.addTrack()` with default
|
||||
options.
|
||||
- Live track count badge (ARIA live region) for screen readers.
|
||||
- `role="list"` on the inner UL and `role="listitem"` on every row.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, TrackList } from "@suaveplan/react-audio";
|
||||
|
||||
export function Studio() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<TrackList />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, TrackList } from "@suaveplan/react-audio";
|
||||
|
||||
export function ReadOnlyTrackList() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<TrackList hideAddTrack className="studio-tracks" />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --------------- | ------------------ | ------- | -------------------------------------- |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
|
||||
| `hideAddTrack` | `boolean \| undef` | `false` | When `true`, hides the add button. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component reads the initial snapshot synchronously on first render
|
||||
through `timeline.getState().tracks`, then mounts a `useEffect` that
|
||||
subscribes to `trackChange` and reads a fresh snapshot on every event.
|
||||
The subscription runs in a layout-safe `useEffect` and unsubscribes
|
||||
on unmount via the engine's returned `off()` handle. The "Add track"
|
||||
click handler eagerly calls `setTracks(timeline.getState().tracks)` so
|
||||
the visual list updates synchronously without waiting for the event
|
||||
loop tick. Each `<TrackRow>` re-subscribes to its own per-track event
|
||||
through `useTrack`, so removing a track from the engine collapses the
|
||||
corresponding row to a tombstone marker without unmounting the list.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `<TrackRow>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<TrackRow>` is the per-track row primitive consumed by `<TrackList>`.
|
||||
It subscribes to a single `Track` through the foundation `useTrack`
|
||||
hook and renders the canonical row controls: editable name, mute /
|
||||
solo toggles, volume + pan sliders, and a remove button. The row
|
||||
collapses to a tombstone marker when the referenced track is removed
|
||||
so the surrounding `<ul>` does not visually re-layout in the
|
||||
intervening render frame.
|
||||
|
||||
`<TrackRow>` can also be used standalone — anywhere you have a track
|
||||
id and a provider mounted you can drop it into a list to render
|
||||
exactly one row.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-renders on `trackChange` for the bound track id via `useTrack`.
|
||||
- Mute / solo as `aria-pressed` toggles with live "Mute" / "Muted"
|
||||
labels (and analogous Solo).
|
||||
- Volume slider clamped `[0, 1]` and pan slider clamped `[-1, 1]`,
|
||||
both step `0.01`. Non-numeric input is ignored.
|
||||
- Name input writes through to `timeline.updateTrack({ name })`.
|
||||
- Remove button calls `useTrack().remove()` and the row immediately
|
||||
collapses to the tombstone.
|
||||
- `hideRemove` suppresses the remove button.
|
||||
- `data-muted` / `data-soloed` attributes for theming hooks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { TrackRow } from "@suaveplan/react-audio";
|
||||
|
||||
export function OneRow({ trackId }: { trackId: string }) {
|
||||
return (
|
||||
<ul>
|
||||
<TrackRow trackId={trackId} />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { TrackRow } from "@suaveplan/react-audio";
|
||||
|
||||
export function ReadOnlyRow({ trackId }: { trackId: string }) {
|
||||
return (
|
||||
<ul>
|
||||
<TrackRow trackId={trackId} hideRemove className="row-tight" />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ------------- | ------------------ | ------- | ------------------------------------------------------------ |
|
||||
| `trackId` | `string` | — | Required. The id of the track to render. |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<li>` (both active and tombstone). |
|
||||
| `hideRemove` | `boolean \| undef` | `false` | Suppresses the remove button when `true`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The active row is rendered by an inner `ActiveRow` sub-component so
|
||||
the mute / solo / volume / pan / name callbacks are only defined when
|
||||
the track exists. This keeps the hooks call sequence stable across
|
||||
the "track exists → tombstone" transition without violating the Rules
|
||||
of Hooks: the parent always calls `useTrack` first, then either
|
||||
returns the tombstone JSX (no further hooks) or renders `<ActiveRow>`
|
||||
(which calls its own bag of `useCallback`s). The tombstone collapses
|
||||
to a minimal `<li>` so list keying remains stable while the engine is
|
||||
mid-mutation.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `<TransportControls>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<TransportControls>` is the composed-plugin UI for the provider-owned
|
||||
`Transport` instance. It exposes the spec-mandated play / pause / stop /
|
||||
seek / loop / tempo surface as a single accessible group of native form
|
||||
controls, mirroring the live `transport.state` and `transport.seconds`
|
||||
without requiring callers to wire `useTransport` or `useTransportTime`
|
||||
themselves.
|
||||
|
||||
The component is intentionally minimal: it is the canonical "transport
|
||||
bar" you drop into a layout and let consumers theme via the
|
||||
`.rax-transport` class. Hotkey support is opt-in for global capture but
|
||||
always available when the component itself has keyboard focus.
|
||||
|
||||
## Features
|
||||
|
||||
- Play / pause / stop buttons bound to `useTransport`.
|
||||
- Live position readout (sampled at the provider's `sampleRateHz`).
|
||||
- Tempo input writes the BPM signal at the current transport time, with
|
||||
configurable `minTempo` / `maxTempo` clamps.
|
||||
- Seek input writes positive-only second offsets, restarting the
|
||||
transport when it was playing.
|
||||
- Loop toggle drives `transport.loop` and reflects the flag.
|
||||
- Local `Space` / `Escape` / `L` hotkeys when focused, and optional
|
||||
global hotkeys via `enableGlobalHotkeys`.
|
||||
- `role="group"`, `aria-pressed`, and `aria-live` polite updates.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AudioProvider, TransportControls } from "@suaveplan/react-audio";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<TransportControls />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AudioProvider,
|
||||
TransportControls,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
export function Workstation() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<TransportControls
|
||||
enableGlobalHotkeys
|
||||
minTempo={40}
|
||||
maxTempo={220}
|
||||
className="studio-transport"
|
||||
initialSeekSeconds={4.5}
|
||||
/>
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --------------------- | ------------------- | ------- | ----------------------------------------------------------- |
|
||||
| `className` | `string \| undef` | — | Forwarded to the outer `<div>`. |
|
||||
| `initialSeekSeconds` | `number \| undef` | `0` | Initial value displayed in the seek input. |
|
||||
| `minTempo` | `number \| undef` | `20` | Tempo input `min` attribute. |
|
||||
| `maxTempo` | `number \| undef` | `300` | Tempo input `max` attribute. |
|
||||
| `enableGlobalHotkeys` | `boolean \| undef` | `false` | When `true`, hotkeys fire from any `document` keydown. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component subscribes to `transport.on("start" \| "stop" \| "pause")`
|
||||
to update its local `state` and renders the play / pause button's label
|
||||
from that. The position readout is provided by `useTransportTime`,
|
||||
which samples at the provider's `sampleRateHz` budget (default 60 Hz)
|
||||
rather than per audio block — display lag of at most one frame is the
|
||||
correct ceiling for UI display. The seek handler stops and restarts the
|
||||
transport via the shared `useTransport().seek` callback to avoid races
|
||||
with the underlying tick source. Global hotkeys are registered with a
|
||||
`useEffect` cleanup so unmounting fully removes the listener; the local
|
||||
keydown handler uses `preventDefault` only for the handled keys
|
||||
(`Space`, `Escape`, `L`) so other keystrokes bubble normally.
|
||||
92
docs/ui/react/react-audio/src/error-codes/error-codes.md
Normal file
92
docs/ui/react/react-audio/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# `@suaveplan/react-audio/error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the single source of truth for every error code thrown
|
||||
inside `@suaveplan/react-audio`. The codes are namespaced under the
|
||||
`REACT_AUDIO.` prefix via `defineCodes` from `@suaveplan/error`, so
|
||||
consumers see stable dotted strings (`REACT_AUDIO.NO_PROVIDER`,
|
||||
`REACT_AUDIO.UNKNOWN_PARAMETER`, …) that can be matched against in
|
||||
catch-handlers or routed through an i18n message resolver.
|
||||
|
||||
Every code declared here is reachable from a real call site exercised
|
||||
by the test suite — there are no "future" codes. The pre-publish gate
|
||||
inspects `REACT_AUDIO_ERROR_METADATA` and the `REACT_AUDIO_CODES`
|
||||
object to assert the two sets agree.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable dotted codes via `defineCodes("REACT_AUDIO", …)`.
|
||||
- Frozen metadata table with `description` and `severity` (`warning` or
|
||||
`error`) per code, suitable for centralised reporting or i18n.
|
||||
- `ReactAudioError extends SuaveplanError<ReactAudioErrorCode>` —
|
||||
every error thrown by the package is an instance of this class.
|
||||
- `registerReactAudioErrorCodes()` idempotent registration hook (kept
|
||||
for parity with sibling packages).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_CODES,
|
||||
ReactAudioError,
|
||||
} from "@suaveplan/react-audio/error-codes";
|
||||
|
||||
try {
|
||||
// …
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ReactAudioError &&
|
||||
error.code === REACT_AUDIO_CODES.NO_PROVIDER
|
||||
) {
|
||||
// hook rendered without an <AudioProvider> ancestor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_ERROR_METADATA,
|
||||
ReactAudioError,
|
||||
type ReactAudioErrorCode,
|
||||
} from "@suaveplan/react-audio/error-codes";
|
||||
|
||||
function reportReactAudioError(err: ReactAudioError): void {
|
||||
const code = err.code as ReactAudioErrorCode;
|
||||
const meta = REACT_AUDIO_ERROR_METADATA[code];
|
||||
console.error(`[${meta.severity}] ${code} — ${meta.description}`, {
|
||||
cause: err.cause,
|
||||
context: (err as { context?: unknown }).context,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_CODES` — frozen object whose values are the dotted code
|
||||
strings.
|
||||
- `ReactAudioErrorCode` — union of the dotted strings.
|
||||
- `REACT_AUDIO_ERROR_METADATA` — frozen
|
||||
`Record<ReactAudioErrorCode, { description, severity }>`.
|
||||
- `ReactAudioError` — class extending `SuaveplanError`; carries the
|
||||
code, an i18n `messageKey`, and optional `cause` / `context`.
|
||||
- `registerReactAudioErrorCodes()` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns an object whose values are pre-formatted dotted
|
||||
strings; passing the bare literal `"REACT_AUDIO.NO_PROVIDER"` works
|
||||
equally well in `instanceof` + `code ===` checks. The metadata object
|
||||
is frozen via `Object.freeze` at module load so consumers cannot
|
||||
accidentally mutate it. `ReactAudioError`'s constructor reads the
|
||||
metadata once and writes a descriptive `name` (`ReactAudioError(error)`
|
||||
or `ReactAudioError(warning)`) so default React error overlays carry
|
||||
useful context. Every throw site in the package routes through this
|
||||
class so a single `error instanceof ReactAudioError` branch catches
|
||||
every package-originated failure.
|
||||
120
docs/ui/react/react-audio/src/hooks/use-analyser/use-analyser.md
Normal file
120
docs/ui/react/react-audio/src/hooks/use-analyser/use-analyser.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# `hooks/use-analyser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and
|
||||
returns the latest byte-magnitude FFT frame plus the matching
|
||||
time-domain (waveform) frame. It is the data feed for the family's
|
||||
two stock visualisers — `<WaveformVisualizer>` and
|
||||
`<SpectrumAnalyzer>` — and for any consumer-built `<canvas>`
|
||||
visualiser that wants live spectrum data without dealing with
|
||||
`AudioContext` or `AnalyserNode` lifecycle.
|
||||
|
||||
The hook deliberately does **not** create or own an `AnalyserNode`.
|
||||
The audio engine builds one analyser per track inside its
|
||||
`TrackSubgraph`, and the player tier hosts the master analyser on
|
||||
the source chain; the consumer hands either node to this hook. Two
|
||||
consumers reading the same analyser independently each get their
|
||||
own buffer pair so they cannot tear each other's frame reads.
|
||||
|
||||
## Features
|
||||
|
||||
- Polls at a caller-tunable interval (default 16 ms ≈ 60 Hz).
|
||||
- Auto-configures `fftSize`, `smoothingTimeConstant`, `minDecibels`,
|
||||
`maxDecibels` on the analyser on every change.
|
||||
- Lazy `factory` fallback for when the analyser is not available at
|
||||
mount (typical: engine still initialising).
|
||||
- Identity-stable `Uint8Array` buffers — mutated in place; the hook
|
||||
never allocates per frame.
|
||||
- Cleanup on unmount cancels the interval.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAnalyser } from "@suaveplan/react-audio";
|
||||
|
||||
function Spectrum({ analyser }) {
|
||||
const { frequency } = useAnalyser({ analyser, fftSize: 1024 });
|
||||
return (
|
||||
<div role="img" aria-label="spectrum">
|
||||
{Array.from(frequency.slice(0, 32)).map((v, i) => (
|
||||
<span key={i} style={{ height: `${v}px` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAnalyser } from "@suaveplan/react-audio";
|
||||
|
||||
// Resolve the engine's per-track analyser via the graph snapshot.
|
||||
function trackAnalyserFactory(adapter, trackId) {
|
||||
return () => {
|
||||
const snap = adapter.snapshotGraph();
|
||||
const node = snap.nodes.find(
|
||||
(n) => n.id === `${trackId}:analyser`
|
||||
);
|
||||
return node?.ref ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
function TrackVisualizer({ adapter, trackId }) {
|
||||
const { frequency, waveform, analyser } = useAnalyser({
|
||||
factory: trackAnalyserFactory(adapter, trackId),
|
||||
intervalMs: 33, // ~30 Hz to save CPU
|
||||
fftSize: 2048,
|
||||
smoothingTimeConstant: 0.6,
|
||||
});
|
||||
if (!analyser) return <span>Connecting…</span>;
|
||||
return (
|
||||
<canvas
|
||||
ref={(c) => drawSpectrum(c, frequency, waveform)}
|
||||
width={512}
|
||||
height={128}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAnalyser(opts?: UseAnalyserOptions): UseAnalyserResult`
|
||||
- `UseAnalyserOptions`:
|
||||
- `analyser?: AnalyserLike | null` — direct ref; supersedes `factory`.
|
||||
- `factory?: () => AnalyserLike | null` — lazy resolver, retried per tick.
|
||||
- `intervalMs?: number` — poll interval (default 16).
|
||||
- `fftSize?: number` — power-of-2 in `[32, 32768]` (default 2048).
|
||||
- `smoothingTimeConstant?: number` — `[0, 1]` (default 0.8).
|
||||
- `minDecibels?: number` — default `-100`.
|
||||
- `maxDecibels?: number` — default `-30`.
|
||||
- `UseAnalyserResult`:
|
||||
- `analyser: AnalyserLike | null` — resolved instance or `null`.
|
||||
- `frequency: Uint8Array` — byte-magnitude FFT, identity-stable.
|
||||
- `waveform: Uint8Array` — byte time-domain, identity-stable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook owns three refs: the resolved analyser, the factory, and
|
||||
the buffer pair. Buffers are reallocated when `analyser` or
|
||||
`fftSize` changes (the new `frequencyBinCount` determines length).
|
||||
Polling uses `setInterval` rather than `requestAnimationFrame`
|
||||
because the former is deterministic under happy-dom — the same
|
||||
reason `useTransportTime` makes the same choice.
|
||||
|
||||
The factory fallback is invoked on every tick until it returns a
|
||||
non-`null` value, then never again. This lets the consumer mount
|
||||
the hook before the engine finishes async init without races: the
|
||||
visualiser simply renders zeros until the analyser appears, then
|
||||
continues seamlessly.
|
||||
|
||||
The hook is engine-agnostic by design — it accepts the structural
|
||||
`AnalyserLike` shape, so consumers can stub it in tests with three
|
||||
lines of code and there is no hidden `AudioContext` dependency
|
||||
poisoning tree-shake or SSR.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `hooks/use-audio-engine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAudioEngine()` returns the full resolved `ProviderState` bundle —
|
||||
the adapter, the `TimelineEngine`, the `Transport`, the
|
||||
`ProviderBusRegistry`, the `PluginRegistry`, the consumer-supplied
|
||||
preset store (or `undefined`), and the configured `sampleRateHz`.
|
||||
It throws `REACT_AUDIO.NO_PROVIDER` outside the provider and
|
||||
`REACT_AUDIO.PROVIDER_PENDING` during the async init window. It is
|
||||
the escape hatch for code that needs the entire bundle at once;
|
||||
prefer the narrower hooks (`useTransport`, `useTimeline`, …) when
|
||||
you only need one entity, as they minimise re-render scope.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `ProviderState` — a readonly bundle.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>`.
|
||||
- Throws `REACT_AUDIO.PROVIDER_PENDING` while engine init is in
|
||||
flight.
|
||||
- Stable reference across re-renders within a single ready phase.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine } from "@suaveplan/react-audio";
|
||||
|
||||
function Diagnostics() {
|
||||
const { engine, transport, sampleRateHz } = useAudioEngine();
|
||||
return (
|
||||
<pre>
|
||||
engine.lookAhead = {engine.lookAhead}; transport.state ={" "}
|
||||
{transport.state}; ui-sampleRate = {sampleRateHz} Hz
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine } from "@suaveplan/react-audio";
|
||||
|
||||
function useRegisterPlugin(): (type: string, factory: () => AudioFxPlugin) => void {
|
||||
const { registry } = useAudioEngine();
|
||||
return (type, factory) => registry.register(type, factory);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAudioEngine(): ProviderState`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook delegates to `useProviderStateOrThrow`, which performs the
|
||||
two structured-error checks. The returned `ProviderState` is the
|
||||
frozen object the provider published — consumers can stash it in a
|
||||
ref without cloning. Identity is stable across renders within a
|
||||
single ready phase; if the provider remounts (Strict Mode or genuine
|
||||
remount) the bundle's identity changes, which is the correct cue for
|
||||
downstream `useEffect`s keyed on `engine`/`transport` to re-bind.
|
||||
Prefer the focused hooks (`useTransport`, `useTimeline`, …) for the
|
||||
common case of "I only need one entity"; the broader bundle returned
|
||||
here triggers re-renders only when the provider itself re-publishes
|
||||
state (the focused hooks emit narrower subscriptions). Consumers
|
||||
that want to forward the engine bundle into child components without
|
||||
re-render bloat should destructure here, then memoise the
|
||||
sub-references they pass downstream.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `hooks/use-audio-fx-plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAudioFxPlugin(pluginType)` instantiates a plugin via the
|
||||
provider's `PluginRegistry.create(pluginType)` and memoises the
|
||||
result. The hook is the canonical way to obtain a one-off plugin
|
||||
instance (e.g. for a side-panel preview that is not in any chain).
|
||||
Consumers who want to insert the plugin into a chain pass the
|
||||
returned value to `usePluginChain(...).add(plugin)`.
|
||||
|
||||
The hook does not own or register factories. The provider can
|
||||
auto-register factories via `options.plugins`; alternatively
|
||||
`useAudioEngine().registry.register(...)` lets consumers add factories
|
||||
dynamically.
|
||||
|
||||
## Features
|
||||
|
||||
- Instance memoised by `pluginType` for component lifetime.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` / `REACT_AUDIO.PROVIDER_PENDING`
|
||||
outside / before-init.
|
||||
- Throws `AudioFxError(AUDIO_FX.CHAIN_SLOT_NOT_FOUND)` if no factory
|
||||
is registered for `pluginType`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAudioFxPlugin } from "@suaveplan/react-audio";
|
||||
|
||||
function PreviewEQ() {
|
||||
const eq = useAudioFxPlugin("equaliser");
|
||||
return <PluginPanel plugin={eq} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine, useAudioFxPlugin, usePluginChain } from "@suaveplan/react-audio";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function EnsureRegistered() {
|
||||
const { registry } = useAudioEngine();
|
||||
useEffect(() => {
|
||||
registry.register("delay", () => makeDelay());
|
||||
}, [registry]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChainAdder({ trackId }: { trackId: string }) {
|
||||
const delay = useAudioFxPlugin("delay");
|
||||
const { add } = usePluginChain(trackId);
|
||||
return <button type="button" onClick={() => add(delay)}>Add delay</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAudioFxPlugin(pluginType: string): AudioFxPlugin`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook memoises on `[registry, pluginType]` so a remount of the
|
||||
provider (new registry identity) yields a fresh instance, but a
|
||||
re-render with the same provider and `pluginType` returns the same
|
||||
instance. The hook does not call `dispose()` on unmount because the
|
||||
plugin is typically inserted into a chain whose lifecycle owns the
|
||||
disposal. Consumers using the hook for a transient preview should
|
||||
call `plugin.dispose()` in their own `useEffect` cleanup. Plugins
|
||||
constructed by this hook are never auto-inserted into any chain —
|
||||
the caller is responsible for placement (e.g. via
|
||||
`usePluginChain(trackId).add(plugin)`). This separation lets the
|
||||
same hook back per-track inserts, side-panel previews, and one-off
|
||||
analyser taps.
|
||||
69
docs/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal file
69
docs/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `hooks/use-bus`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useBus(name)` reads the named sidechain bus from the provider's
|
||||
`ProviderBusRegistry` and subscribes to its publish/close lifecycle.
|
||||
Returns `undefined` if no publisher has registered the name. The
|
||||
hook re-renders whenever the bus is (re)published with a new
|
||||
channel layout or closed.
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore`-based subscription to the registry's per-name
|
||||
listener set.
|
||||
- Returns `undefined` when the bus is closed or never published —
|
||||
render-safe.
|
||||
- SSR-consistent snapshot (both `getSnapshot` and
|
||||
`getServerSnapshot` read `buses.get(name)`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useBus } from "@suaveplan/react-audio";
|
||||
|
||||
function SidechainStatus({ name }: { name: string }) {
|
||||
const bus = useBus(name);
|
||||
return <span>{bus ? `${bus.channels} ch` : "(no bus)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine, useBus } from "@suaveplan/react-audio";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function PublishKick() {
|
||||
const { buses } = useAudioEngine();
|
||||
useEffect(() => {
|
||||
const pub = buses.publish("kick", 1);
|
||||
return () => pub.close();
|
||||
}, [buses]);
|
||||
|
||||
const bus = useBus("kick");
|
||||
return <span>bus is {bus ? "live" : "closed"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useBus(name: string): SidechainBus | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The subscribe callback returns the registry's existing
|
||||
`subscribe(name, listener)` teardown so cleanup is exact. Reading
|
||||
through `buses.get(name)` is O(1) on the registry's internal map.
|
||||
The `subscribe` listener is invoked from within `publish()` (when a
|
||||
bus appears) and from within `close()` (when one disappears), so a
|
||||
React commit happens for both transitions. The hook returns the
|
||||
`SidechainBus` itself rather than a wrapper — consumers can call
|
||||
`bus.read(into)` to pull a buffer from the bus on any tick. Phase-2
|
||||
meter components and any future scope/analyser views are expected
|
||||
to consume buses through this hook, so its surface is intentionally
|
||||
minimal: one accessor, undefined when absent, narrow subscription.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `hooks/use-chain-plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin`
|
||||
currently occupying a specific slot in a track's `PluginChain`, or
|
||||
`undefined` if the slot is empty. It is the per-slot view of
|
||||
`usePluginChain` — useful for rendering a single
|
||||
`<PluginSlot plugin={...} />` without re-rendering on every other
|
||||
slot's parameter change. The hook re-renders only when chain
|
||||
composition changes affect the targeted slot (`plugin-added`,
|
||||
`plugin-removed`, `plugin-moved`, `plugin-replaced`).
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore`-based subscription to four chain events.
|
||||
- Returns `undefined` for out-of-range or empty slots — render-safe.
|
||||
- Stable behaviour across React 18+ concurrent rendering.
|
||||
- Listener cleanup on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useChainPlugin } from "@suaveplan/react-audio";
|
||||
|
||||
function SlotName({ trackId, slot }: { trackId: string; slot: number }) {
|
||||
const plugin = useChainPlugin(trackId, slot);
|
||||
return <span>{plugin?.metadata.name ?? "(empty)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useChainPlugin, useParameter } from "@suaveplan/react-audio";
|
||||
|
||||
function SlotGain({ trackId, slot }: { trackId: string; slot: number }) {
|
||||
const plugin = useChainPlugin(trackId, slot);
|
||||
if (plugin === undefined) return null;
|
||||
return <ParameterKnob plugin={plugin} paramId="gain" />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useChainPlugin(trackId, slotIndex): AudioFxPlugin | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook subscribes to four chain events but reads only
|
||||
`chain.plugins[slotIndex]` in its snapshot, so React re-renders the
|
||||
consumer when (and only when) one of those events fires. Reading by
|
||||
index is O(1) on the chain's internal array. `useSyncExternalStore`
|
||||
returns the same plugin reference for the same slot across re-renders
|
||||
when nothing about that slot changed, so downstream `useMemo` /
|
||||
`useEffect` keyed on the plugin behave correctly. The hook ignores
|
||||
`parameter-changed` and `bypass-changed` events because they don't
|
||||
move the slot's plugin — for per-parameter subscriptions a child
|
||||
should use `useParameter`. The list of subscribed events therefore
|
||||
captures the smallest set of changes that could affect the value
|
||||
returned, keeping per-slot re-renders proportional to actual chain
|
||||
edits.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `hooks/use-chain-snapshot`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useChainSnapshot(chain)` exposes chain-level snapshot operations.
|
||||
`get` reads the in-memory `ChainSnapshot`, `restore` applies one,
|
||||
and `save` / `load` persist through the provider's optional preset
|
||||
store, keyed by id. The hook is the natural complement to
|
||||
`usePluginChain` for "FX rack" UIs that save and load entire chains
|
||||
as a unit.
|
||||
|
||||
## Features
|
||||
|
||||
- `get()` — synchronous in-memory read of `chain.snapshot()`.
|
||||
- `restore(input)` — async; delegates to `chain.restore(input, registry)`.
|
||||
- `save(id)` — persists the snapshot via the preset store.
|
||||
- `load(id)` — loads + applies via the preset store.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for every failure mode.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useChainSnapshot } from "@suaveplan/react-audio";
|
||||
|
||||
function ChainPresetButtons({ chain }: { chain: PluginChain }) {
|
||||
const { save, load } = useChainSnapshot(chain);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save chain</button>
|
||||
<button type="button" onClick={() => load("default")}>Load chain</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useChainSnapshot } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportImport({ chain }: { chain: PluginChain }) {
|
||||
const { get, restore } = useChainSnapshot(chain);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => navigator.clipboard.writeText(JSON.stringify(get()))}>
|
||||
Copy JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const text = await navigator.clipboard.readText();
|
||||
await restore(JSON.parse(text));
|
||||
}}
|
||||
>
|
||||
Paste JSON
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useChainSnapshot(chain): { get, restore, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook needs the provider's `PluginRegistry` to instantiate
|
||||
plugins during `restore`, so it consumes `useProviderStateOrThrow`.
|
||||
Every failure mode of the preset store is wrapped into a
|
||||
`ReactAudioError(PRESET_STORE_FAILED)` with the underlying error as
|
||||
`cause`; the absence of a preset store and the "preset id not
|
||||
found" case use the same code with different `messageKey`s so a
|
||||
catch-handler can branch on the message resolver if needed. The
|
||||
`get()` helper returns the raw `chain.snapshot()` which includes
|
||||
the chain's `blockSize` and `sampleRate`; consumers exporting JSON
|
||||
for cross-instance restore should preserve those fields verbatim,
|
||||
because `chain.restore` re-validates them against the chain's own
|
||||
config on the receiving side. Non-Error throws from the store are
|
||||
narrowed to `undefined` causes (the JS `instanceof Error` ternary).
|
||||
@@ -0,0 +1,73 @@
|
||||
# `hooks/use-engine-state`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useEngineState()` reports the `<AudioProvider>`'s async-init
|
||||
lifecycle phase as one of `'pending' | 'ready' | 'error'`. It is the
|
||||
only engine-aware hook that does **not** throw on the pending phase —
|
||||
its purpose is to *report* that phase so consumers can render a
|
||||
loading state or gate child trees on engine readiness.
|
||||
|
||||
The hook still throws `ReactAudioError(REACT_AUDIO.NO_PROVIDER)` when
|
||||
rendered outside an `<AudioProvider>` ancestor, mirroring the
|
||||
contract every public hook honours.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `'pending'`, `'ready'`, or `'error'`.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside the provider.
|
||||
- Triggers re-render when the engine resolves (via the provider's
|
||||
internal `setEngineState`).
|
||||
- Stable identity across re-renders for the same lifecycle phase.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useEngineState } from "@suaveplan/react-audio";
|
||||
|
||||
function App() {
|
||||
const engineState = useEngineState();
|
||||
if (engineState === "pending") return <p>Loading audio…</p>;
|
||||
if (engineState === "error") return <p>Audio failed to initialise.</p>;
|
||||
return <Workspace />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useEngineState } from "@suaveplan/react-audio";
|
||||
|
||||
function useIsReady(): boolean {
|
||||
return useEngineState() === "ready";
|
||||
}
|
||||
|
||||
function Gate({ children }: { children: React.ReactNode }) {
|
||||
const ready = useIsReady();
|
||||
return ready ? <>{children}</> : <Spinner />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useEngineState(): 'pending' | 'ready' | 'error'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useEngineState` reads the provider's React context directly rather
|
||||
than going through `useSyncExternalStore`. React already batches state
|
||||
updates inside the provider, so reading the context value is
|
||||
sufficient and avoids the indirection of a snapshot+subscribe pair.
|
||||
The hook returns the same `engineState` string instance across
|
||||
renders within the same phase, so a `useMemo` keyed on it is a
|
||||
constant-time identity check. The `'error'` state is reachable when
|
||||
the engine's async init rejects (e.g. the browser refuses to start
|
||||
an `AudioContext`). The provider's error object is exposed on the
|
||||
context value too — consumers needing the underlying failure can
|
||||
combine `useEngineState() === 'error'` with `useAudioEngine` or a
|
||||
direct context read. The hook is the ONE hook in this package that
|
||||
deliberately does NOT throw on the pending phase.
|
||||
@@ -0,0 +1,112 @@
|
||||
# `hooks/use-midi-controller`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useMidiController(controllerId)` is the React 19 binding for a single
|
||||
`MidiControllerPlugin` from `@suaveplan/audio-controllers`. It looks
|
||||
the controller up by `id` in the surrounding
|
||||
`MidiControllerRegistryContext`, subscribes to the controller's
|
||||
`state` event via `useSyncExternalStore`, and exposes a `learn(target)`
|
||||
method that wraps `captureNextLearn` for the learn-mode UX.
|
||||
|
||||
The hook is intentionally narrow: it does **not** own the MIDI
|
||||
hardware, nor does it instantiate the controller. The application
|
||||
constructs the controller plugin(s) at startup, places them in a
|
||||
read-only registry (a plain `Map` is sufficient), and passes that
|
||||
registry into the React tree via
|
||||
`<MidiControllerRegistryContext.Provider>`. Every Phase-3 surface
|
||||
(`useMidiController` and `<MidiControllerSetup>`) reads through the
|
||||
same context, so swapping registry implementations during tests is
|
||||
trivial.
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore` subscription against the controller's
|
||||
`state` event — re-renders once per FSM transition
|
||||
(`disconnected → connecting → connected → error`).
|
||||
- `learn(target)` returns a promise resolving to the captured
|
||||
`(channel, controller)` pair; rejects with
|
||||
`REACT_AUDIO.MIDI_LEARN_FAILED` when no controller is bound or
|
||||
`captureNextLearn` rejects.
|
||||
- `controller` is `undefined` and `state` is `"disconnected"` when
|
||||
the registry does not contain the requested id — no throw, no
|
||||
exception during render.
|
||||
- Optional `registryOverride` second argument bypasses the context
|
||||
for tests / isolated stories.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import {
|
||||
MidiControllerRegistryContext,
|
||||
useMidiController,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
function ControllerBadge({ id }: { id: string }) {
|
||||
const { state } = useMidiController(id);
|
||||
return <span data-state={state}>{state}</span>;
|
||||
}
|
||||
|
||||
function App({ controllers }: { controllers: Map<string, MidiControllerPlugin> }) {
|
||||
return (
|
||||
<MidiControllerRegistryContext.Provider value={controllers}>
|
||||
<ControllerBadge id="kontrol-s88" />
|
||||
</MidiControllerRegistryContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useMidiController } from "@suaveplan/react-audio";
|
||||
|
||||
function LearnButton({ id, pluginId, paramId }: {
|
||||
id: string; pluginId: string; paramId: string;
|
||||
}) {
|
||||
const { controller, state, learn } = useMidiController(id);
|
||||
if (state !== "connected" || !controller) return <p>Connect controller…</p>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const captured = await learn({ pluginId, paramId });
|
||||
console.log("captured", captured);
|
||||
}}
|
||||
>
|
||||
Learn {paramId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MidiControllerRegistry` — type alias for
|
||||
`ReadonlyMap<string, MidiControllerPlugin>`.
|
||||
- `MidiControllerRegistryContext` — React context whose `.Provider`
|
||||
distributes the registry to descendants.
|
||||
- `LearnTarget` — `{ pluginId, paramId }` pair.
|
||||
- `UseMidiControllerResult.controller` — resolved plugin or `undefined`.
|
||||
- `UseMidiControllerResult.state` — `ConnectionState` mirrored from
|
||||
the controller FSM.
|
||||
- `UseMidiControllerResult.learn(target)` — returns
|
||||
`Promise<{ target, channel, controller }>`; rejects with
|
||||
`REACT_AUDIO.MIDI_LEARN_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useSyncExternalStore` requires that `getSnapshot` be referentially
|
||||
stable for unchanged state. Because `ConnectionState` is a string
|
||||
union, the snapshot value itself is the comparison key, so the
|
||||
default `Object.is` equality check fires re-renders only on actual
|
||||
transitions. The `subscribe` callback returns the controller's own
|
||||
`off`-style unsubscribe handle, so the hook scales to any number of
|
||||
mounted consumers without leaking listeners. Errors during `learn`
|
||||
are wrapped in `ReactAudioError(MIDI_LEARN_FAILED)` so consumers can
|
||||
handle a single, stable code rather than poking at the underlying
|
||||
`AudioControllersError` surface.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `hooks/use-parameter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useParameter(plugin, paramId)` is the canonical read/write hook for
|
||||
a single declared parameter on any `AudioFxPlugin`. It returns the
|
||||
parameter's current value (sampled at frame rate) and three
|
||||
write-paths: an immediate `set`, a `curve` for ramps/automation, and
|
||||
a `scheduleAt` for transport-anchored value changes.
|
||||
|
||||
The hook throws `ReactAudioError(REACT_AUDIO.UNKNOWN_PARAMETER)`
|
||||
synchronously on first render if the `paramId` is not declared in
|
||||
the plugin's `parameterIndex` — so a typo fails fast in the render
|
||||
tree rather than silently dropping writes.
|
||||
|
||||
## Features
|
||||
|
||||
- Frame-rate sampled `value` (60 Hz default, configurable via
|
||||
provider `sampleRateHz`).
|
||||
- `set(v)` → `plugin.setParameter(paramId, v)`.
|
||||
- `curve(c)` → `plugin.setParameterCurve(paramId, c)`.
|
||||
- `scheduleAt(v, t)` → `plugin.setParameterAt(paramId, v, t)`.
|
||||
- Throws `REACT_AUDIO.UNKNOWN_PARAMETER` on first render for an
|
||||
unknown `paramId`, carrying the available ids in `context`.
|
||||
- SSR-consistent snapshots (both `getSnapshot` and
|
||||
`getServerSnapshot` read `plugin.getParameter(paramId)`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useParameter } from "@suaveplan/react-audio";
|
||||
|
||||
function GainKnob({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { value, set } = useParameter(plugin, "gain");
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={value}
|
||||
onChange={(e) => set(Number(e.currentTarget.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useParameter } from "@suaveplan/react-audio";
|
||||
|
||||
function Sweep({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { curve, scheduleAt } = useParameter(plugin, "cutoff");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
curve({
|
||||
kind: "linear",
|
||||
startValue: 200,
|
||||
endValue: 8000,
|
||||
duration: 4,
|
||||
})
|
||||
}
|
||||
>
|
||||
Linear sweep
|
||||
</button>
|
||||
<button type="button" onClick={() => scheduleAt(440, 2)}>
|
||||
Set to 440 at t=2s
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useParameter(plugin, paramId): { value, set, curve, scheduleAt }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Parameter automation can change values per audio block (~689 Hz at
|
||||
128/88.2k); the React commit cycle cannot run at that rate. The hook
|
||||
samples the live value at `sampleRateHz` (60 Hz default) via a
|
||||
`useSyncExternalStore` subscription whose subscribe path schedules a
|
||||
`setInterval` and getSnapshot path reads `plugin.getParameter(paramId)`.
|
||||
`setInterval` is used rather than `requestAnimationFrame` so the
|
||||
sampling behaves deterministically in tests (happy-dom RAF is
|
||||
synchronous-per-microtask which makes the per-frame-cap test
|
||||
impossible). The unknown-param check runs on every render —
|
||||
parameter indices are immutable on a typical plugin so the lookup is
|
||||
O(1) and the throw site is the same.
|
||||
135
docs/ui/react/react-audio/src/hooks/use-playlist/use-playlist.md
Normal file
135
docs/ui/react/react-audio/src/hooks/use-playlist/use-playlist.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# `hooks/use-playlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries
|
||||
and exposes the standard playlist verbs (`next`, `prev`, `skipTo`,
|
||||
`setIndex`) plus mode toggles (`repeat`, `shuffle`). The hook is
|
||||
deliberately engine-agnostic: it knows nothing about `AudioContext`,
|
||||
`AudioPlayerPlugin`, or transport. Consumers wire the returned
|
||||
`current.src` into whichever loader they use, typically
|
||||
`<AudioPlayer source={current.src}>` from this same package, or any
|
||||
of the higher-level players that build on it.
|
||||
|
||||
The hook is the single source of truth for queue traversal across
|
||||
the audio family. The companion `<Queue>` UI in
|
||||
`@suaveplan/react-audio-player` is a thin renderer over its return
|
||||
value; the karaoke surface in the same package advances lyrics
|
||||
independently from playlist position.
|
||||
|
||||
## Features
|
||||
|
||||
- `repeat: "off" | "all" | "one"` with correct wrap semantics on
|
||||
`next` and `prev`.
|
||||
- `shuffle: boolean` with a per-shuffle history stack so `prev`
|
||||
reverses the exact path `next` walked.
|
||||
- Avoids immediately repeating the current track when shuffling.
|
||||
- `skipTo(id)` jumps to an item by id; ids not in `items` are a
|
||||
silent no-op.
|
||||
- `setIndex(n)` clamps out-of-range values rather than throwing.
|
||||
- Injectable `rng` for deterministic testing.
|
||||
- Stable return-value identity for `current` across renders that
|
||||
do not change the active item.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePlaylist } from "@suaveplan/react-audio";
|
||||
|
||||
function MyPlayer({ items }) {
|
||||
const { current, next, prev, index, length } = usePlaylist(items);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={prev} disabled={index <= 0}>
|
||||
←
|
||||
</button>
|
||||
<span>{current?.title ?? "—"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= length - 1}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePlaylist } from "@suaveplan/react-audio";
|
||||
|
||||
function ShuffledPlayer({ items }) {
|
||||
// Inject a deterministic RNG for repeatable tests.
|
||||
const rng = () => 0.42;
|
||||
const pl = usePlaylist(items, {
|
||||
initialIndex: 2,
|
||||
initialRepeat: "all",
|
||||
initialShuffle: true,
|
||||
rng,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => pl.setShuffle(!pl.shuffle)}>
|
||||
{pl.shuffle ? "Shuffle on" : "Shuffle off"}
|
||||
</button>
|
||||
<button type="button" onClick={() => pl.setRepeat("one")}>
|
||||
Repeat one
|
||||
</button>
|
||||
<ol>
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.id}
|
||||
aria-current={
|
||||
pl.current?.id === it.id ? "true" : undefined
|
||||
}
|
||||
>
|
||||
<button type="button" onClick={() => pl.skipTo(it.id)}>
|
||||
{it.title ?? it.id}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePlaylist(items: readonly PlaylistItem[], opts?: UsePlaylistOptions): UsePlaylistResult`
|
||||
- `PlaylistItem` — `{ id, src, title?, artist?, durationSec?, meta? }`.
|
||||
- `PlaylistRepeat` — `"off" | "all" | "one"`.
|
||||
- `UsePlaylistOptions` — `initialIndex`, `initialRepeat`,
|
||||
`initialShuffle`, `rng`.
|
||||
- `UsePlaylistResult` — `current`, `index`, `length`, `repeat`,
|
||||
`shuffle`, `next()`, `prev()`, `skipTo(id)`, `setIndex(n)`,
|
||||
`setRepeat(mode)`, `setShuffle(enabled)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cursor is held in a single `useState<number>`; all verbs route
|
||||
through the same setter so React batches updates per event. The
|
||||
shuffle "history" lives in a `useRef<number[]>` because it is not
|
||||
rendered — pushing to it must not cause a commit. When `setShuffle`
|
||||
toggles off, the history is cleared so the next shuffle session
|
||||
starts clean.
|
||||
|
||||
The `rng` ref is rebound on every render to `opts.rng ?? Math.random`
|
||||
so the consumer can swap RNGs without losing state. The deferred
|
||||
update inside callbacks reads the ref, not the captured option, so a
|
||||
late-arriving RNG takes effect on the very next `next`.
|
||||
|
||||
`current` is memoised by `(items, index, length)`; since `length`
|
||||
mirrors `items.length`, the dep set is minimal but explicit so React
|
||||
strict-mode double-renders cannot manufacture stale snapshots.
|
||||
|
||||
`next` and `prev` are pure functions of `(repeat, shuffle, length,
|
||||
cur)` — no I/O, no thrown errors, no `console`. The hook is safe to
|
||||
mount under React's concurrent renderer without `useTransition`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `hooks/use-plugin-chain`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePluginChain(trackId)` returns a handle for the `PluginChain`
|
||||
associated with a track. The provider lazily creates the chain
|
||||
through its `ChainMap` on first call, so every consumer asking for
|
||||
the same track sees the same chain. The hook subscribes to the
|
||||
chain's composition events — `plugin-added`, `plugin-removed`,
|
||||
`plugin-moved`, `plugin-replaced`, `bypass-changed` — and re-renders
|
||||
the consumer when any of them fire. Listener cleanup on unmount is
|
||||
exact: the chain's listener count returns to the pre-mount baseline.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy per-track chain creation via the provider's `ChainMap`.
|
||||
- Re-renders on chain-composition events (5 events subscribed).
|
||||
- Stable `add` / `remove` / `move` / `replace` / `bypass` /
|
||||
`snapshot` / `restore` callback identities across re-renders.
|
||||
- `restore(input)` delegates to `chain.restore(input, registry)`
|
||||
using the provider's `PluginRegistry`.
|
||||
- Exact listener cleanup on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePluginChain } from "@suaveplan/react-audio";
|
||||
|
||||
function ChainBar({ trackId, eq }: { trackId: string; eq: AudioFxPlugin }) {
|
||||
const { plugins, add } = usePluginChain(trackId);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => add(eq)}>Add EQ</button>
|
||||
<span>{plugins.length} plugin(s)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePluginChain } from "@suaveplan/react-audio";
|
||||
|
||||
function Reorderable({ trackId }: { trackId: string }) {
|
||||
const { plugins, move, bypass, replace } = usePluginChain(trackId);
|
||||
return (
|
||||
<ul>
|
||||
{plugins.map((plugin, index) => (
|
||||
<li key={plugin.metadata.id}>
|
||||
<button onClick={() => move(index, Math.max(0, index - 1))}>↑</button>
|
||||
<button onClick={() => bypass(index, true)}>Bypass</button>
|
||||
<span>{plugin.metadata.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePluginChain(trackId): UsePluginChainResult`.
|
||||
- `UsePluginChainResult = { chain, plugins, add, remove, move,
|
||||
replace, bypass, snapshot, restore }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The subscribe path registers a listener on each of the five
|
||||
composition events and returns a teardown that calls every
|
||||
`UnsubscribeFn`. Because the chain's `event-bus` increments and
|
||||
decrements listener counts symmetrically, the post-unmount listener
|
||||
count is exactly the pre-mount count. `replace` is implemented as
|
||||
`remove + add` at the same index because `PluginChain` does not
|
||||
currently expose a single-call `replace`; the chain emits a
|
||||
`plugin-removed` followed by a `plugin-added`, both of which the
|
||||
hook is subscribed to, so the consumer sees one composite re-render
|
||||
(React batches state updates within the same task).
|
||||
81
docs/ui/react/react-audio/src/hooks/use-preset/use-preset.md
Normal file
81
docs/ui/react/react-audio/src/hooks/use-preset/use-preset.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# `hooks/use-preset`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePreset(plugin)` exposes plugin-level preset operations as
|
||||
React-friendly callbacks: `get` reads the current preset, `apply`
|
||||
writes one back, and `save` / `load` persist through the provider's
|
||||
optional preset store. The hook gates `save`/`load` on the presence
|
||||
of a store; if none is configured (or if the store throws), the hook
|
||||
raises `ReactAudioError(REACT_AUDIO.PRESET_STORE_FAILED)` carrying
|
||||
the offending id and the underlying cause.
|
||||
|
||||
## Features
|
||||
|
||||
- `get(): PresetSnapshot` — synchronous read.
|
||||
- `apply(preset): void` — synchronous write.
|
||||
- `save(id): Promise<void>` — async write through the preset store.
|
||||
- `load(id): Promise<void>` — async read + apply.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for missing store, save
|
||||
failure, load failure, and preset-not-found.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function PresetButtons({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { save, load } = usePreset(plugin);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save</button>
|
||||
<button type="button" onClick={() => load("default")}>Load</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportPreset({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { get } = usePreset(plugin);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const preset = get();
|
||||
navigator.clipboard.writeText(JSON.stringify(preset));
|
||||
}}
|
||||
>
|
||||
Copy JSON
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePreset(plugin): { get, apply, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook wraps each store interaction in a try/catch that converts
|
||||
underlying errors into `ReactAudioError(PRESET_STORE_FAILED)` with
|
||||
the original `Error` set as `cause`. The store is allowed to be
|
||||
undefined; consumers that don't need persistence omit it from
|
||||
provider options and use `get` / `apply` only. Promise rejections are
|
||||
deliberately wrapped (rather than re-thrown raw) so a single
|
||||
`error instanceof ReactAudioError` branch handles every failure path.
|
||||
The four failure modes — missing store, store-rejected save,
|
||||
store-rejected load, preset-not-found — share one code
|
||||
(`PRESET_STORE_FAILED`) but distinct `messageKey`s so an i18n layer
|
||||
can branch on the message resolver instead of the code. Non-Error
|
||||
values thrown by a store are narrowed to `undefined` causes so the
|
||||
wrapper Error remains type-safe.
|
||||
70
docs/ui/react/react-audio/src/hooks/use-provider-state.md
Normal file
70
docs/ui/react/react-audio/src/hooks/use-provider-state.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# `hooks/use-provider-state`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helpers shared across every public hook. They centralise the
|
||||
two structured-error throws every engine-dependent hook must perform:
|
||||
`REACT_AUDIO.NO_PROVIDER` when the consumer renders outside an
|
||||
`<AudioProvider>` ancestor, and `REACT_AUDIO.PROVIDER_PENDING` when
|
||||
the engine has not yet finished its async init. Keeping the throw
|
||||
logic in one file means there is exactly one place to audit the error
|
||||
contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `useProviderStateOrThrow()` — returns the resolved `ProviderState`
|
||||
or throws.
|
||||
- `useAudioContextOrThrow()` — returns the raw `AudioContextValue`
|
||||
(without the pending check) for hooks like `useEngineState` that
|
||||
need to *report* the lifecycle phase rather than throw on it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useProviderStateOrThrow } from "@suaveplan/react-audio";
|
||||
|
||||
function MyHook() {
|
||||
const state = useProviderStateOrThrow();
|
||||
return state.transport;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { useAudioContextOrThrow } from "@suaveplan/react-audio";
|
||||
|
||||
function useReadyOrLoading(): "ready" | "loading" {
|
||||
const ctx = useAudioContextOrThrow();
|
||||
return ctx.engineState === "ready" ? "ready" : "loading";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useProviderStateOrThrow(): ProviderState` — throws
|
||||
`REACT_AUDIO.NO_PROVIDER` outside provider; throws
|
||||
`REACT_AUDIO.PROVIDER_PENDING` while engine init is pending.
|
||||
- `useAudioContextOrThrow(): AudioContextValue` — throws
|
||||
`REACT_AUDIO.NO_PROVIDER` outside provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useContext` returns `null` as the "no provider" sentinel because the
|
||||
context's default value is `null`. The two helpers use a single
|
||||
discriminator check (`ctx === null`) for that path so the throw site
|
||||
is canonical. `useProviderStateOrThrow` additionally checks for
|
||||
`ctx.state === null` (the async init window) and throws
|
||||
`PROVIDER_PENDING` so engine-dependent hooks fail fast — consumers
|
||||
typically gate on `useEngineState() === 'ready'` before rendering
|
||||
those hooks' subtrees. The split into two helpers exists so
|
||||
`useEngineState` itself can read the phase without throwing on it —
|
||||
otherwise the consumer would have no way to discover "we're still
|
||||
initialising" without crashing the component tree. Both helpers
|
||||
register exactly one React hook (`useContext`) so any downstream hook
|
||||
can register additional `useCallback` / `useSyncExternalStore` calls
|
||||
unconditionally and stay compliant with the Rules of Hooks.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `hooks/use-timeline`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTimeline()` returns the provider's single `TimelineEngine`
|
||||
instance, the canonical owner of timeline state — tracks, clips,
|
||||
selection, loop region, master volume, undo/redo. The engine exposes
|
||||
its own typed event surface (`engine.on(event, listener)`); consumers
|
||||
who want to *subscribe* to engine events should pair this hook with
|
||||
`useSyncExternalStore` and pass `engine.on(...)` as the subscribe
|
||||
callback. The hook itself returns the engine handle and does not
|
||||
trigger re-renders on engine state changes.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns the `TimelineEngine` instance owned by the provider.
|
||||
- Stable identity across re-renders within a ready phase.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>`.
|
||||
- Throws `REACT_AUDIO.PROVIDER_PENDING` during async init.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTimeline } from "@suaveplan/react-audio";
|
||||
|
||||
function AddTrackButton() {
|
||||
const timeline = useTimeline();
|
||||
return (
|
||||
<button type="button" onClick={() => timeline.addTrack({ name: "New Track" })}>
|
||||
Add track
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTimeline } from "@suaveplan/react-audio";
|
||||
|
||||
function useTrackCount(): number {
|
||||
const timeline = useTimeline();
|
||||
return useSyncExternalStore(
|
||||
(listener) => timeline.on("trackChange", listener as never),
|
||||
() => timeline.getState().tracks.length,
|
||||
() => timeline.getState().tracks.length
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTimeline(): TimelineEngine` — see
|
||||
`@suaveplan/audio-timeline` for the full engine surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook deliberately stops short of re-rendering on every engine
|
||||
event. The `TimelineEngine` has a rich change surface and re-rendering
|
||||
on every state delta would be expensive at chain scale. Consumers
|
||||
choose which events they care about and pair the engine handle with
|
||||
`useSyncExternalStore` for narrow subscriptions, paying only for the
|
||||
re-renders they want. Mutator methods (`addTrack`, `updateTrack`,
|
||||
`removeTrack`, `setTempo`, `setMasterVolume`, …) are exposed directly
|
||||
on the engine handle — callers wrap them in their own callbacks when
|
||||
they need referential stability across renders. For per-track state
|
||||
prefer `useTrack(id)`, which already wraps `useSyncExternalStore`
|
||||
against the timeline's `trackChange` event so its subscription is
|
||||
narrow by construction.
|
||||
74
docs/ui/react/react-audio/src/hooks/use-track/use-track.md
Normal file
74
docs/ui/react/react-audio/src/hooks/use-track/use-track.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `hooks/use-track`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTrack(trackId)` subscribes to a single track on the
|
||||
`TimelineEngine` by id and re-renders only when that specific track
|
||||
changes. It returns the live `Track` snapshot (or `undefined` if no
|
||||
track has that id) plus pre-bound `update` / `remove` callbacks. The
|
||||
hook is the canonical way to render per-track UI without re-rendering
|
||||
on every other track's state change.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-renders only on `trackChange` events for the same id.
|
||||
- Returns `undefined` for unknown ids — render-safe.
|
||||
- Stable `update` / `remove` identities across re-renders.
|
||||
- `useSyncExternalStore`-based subscription with SSR-consistent
|
||||
snapshots.
|
||||
- Listener is removed on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTrack } from "@suaveplan/react-audio";
|
||||
|
||||
function TrackName({ trackId }: { trackId: string }) {
|
||||
const { track } = useTrack(trackId);
|
||||
return <span>{track?.name ?? "(missing track)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTrack } from "@suaveplan/react-audio";
|
||||
|
||||
function MuteToggle({ trackId }: { trackId: string }) {
|
||||
const { track, update } = useTrack(trackId);
|
||||
if (track === undefined) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={track.muted}
|
||||
onClick={() => update({ muted: !track.muted })}
|
||||
>
|
||||
{track.muted ? "Unmute" : "Mute"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTrack(trackId): { track: Track | undefined, update, remove }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook caches the resolved Track in a `ref` so
|
||||
`useSyncExternalStore.getSnapshot` is identity-stable between change
|
||||
events. Without that cache, `timeline.getState().tracks.find(...)`
|
||||
would build a new wrapper on every snapshot read; React 18+ treats
|
||||
that as an unstable store and tears the consuming subtree down on
|
||||
the next commit. Refresh fires on every `trackChange` from the
|
||||
timeline AND from local `update` / `remove` mutators — the
|
||||
underlying engine emits `trackChange` only for tracks STILL present
|
||||
in the list, so removing the last track of an id needs the mutator
|
||||
to update the cache directly. After removal the hook continues to
|
||||
render but `track` becomes `undefined`. Components consuming
|
||||
`update` / `remove` should expect those callbacks to settle
|
||||
synchronously; the next React commit reflects the change.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `hooks/use-transport-time`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransportTime()` returns the transport's current time in seconds,
|
||||
sampled at the provider's configured `sampleRateHz` (default 60).
|
||||
The audio engine advances at audio-block rate (typically ~689 Hz for
|
||||
128-sample blocks at 88.2 kHz); React cannot render at that rate, so
|
||||
the hook deliberately samples at frame rate. The returned value lags
|
||||
real time by at most one frame interval (~16.7 ms at 60 Hz) — the
|
||||
correct ceiling for human-readable transport displays.
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore`-based subscription for concurrent-render
|
||||
safety and SSR consistency.
|
||||
- Sample rate configurable via `<AudioProvider options={{ sampleRateHz }}>`.
|
||||
- Cleanup on unmount cancels the timer — no leaked intervals.
|
||||
- SSR snapshot equals first client render (both read `transport.seconds`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function TimeDisplay() {
|
||||
const seconds = useTransportTime();
|
||||
return <span>{seconds.toFixed(3)} s</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function BeatLight() {
|
||||
const seconds = useTransportTime();
|
||||
const beat = Math.floor(seconds * 2) % 4; // 120 bpm assumed
|
||||
return <div data-beat={beat} className="beat-light" />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransportTime(): number` — current transport time in seconds,
|
||||
sampled at `sampleRateHz`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook uses `setInterval` rather than `requestAnimationFrame`
|
||||
because the former is deterministic under happy-dom and Vitest (RAF
|
||||
in JSDOM-equivalents is synchronous and fires once per microtask,
|
||||
making the per-frame-cap test impossible to write). The interval is
|
||||
clamped to a minimum of 1 ms so a runaway `sampleRateHz` cannot lock
|
||||
up the UI. On unmount the interval is cleared via the
|
||||
`useSyncExternalStore` subscribe contract. The `getServerSnapshot`
|
||||
and `getSnapshot` both read `transport.seconds`, so SSR-rendered
|
||||
HTML byte-matches the first client commit.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `hooks/use-transport`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransport()` wraps the provider's `Transport` with a small,
|
||||
React-friendly façade: `{ transport, play, pause, stop, seek,
|
||||
setTempo }`. The wrapped callbacks are stable across re-renders so
|
||||
consumers can pass them to memoised children without losing equality.
|
||||
The underlying `Transport` is included in the result for consumers
|
||||
that need its full surface (loops, swing, schedule, event
|
||||
subscriptions via `transport.on(...)`).
|
||||
|
||||
## Features
|
||||
|
||||
- `play(time?, offset?)` — delegates to `transport.start`.
|
||||
- `pause(time?)` — delegates to `transport.pause`.
|
||||
- `stop(time?)` — delegates to `transport.stop`.
|
||||
- `seek(seconds)` — restarts the transport at the requested offset
|
||||
(converted to ticks via `transport.toTicks`).
|
||||
- `setTempo(bpm)` — writes the bpm signal at the current clock.
|
||||
- `transport` — escape hatch for advanced surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransport } from "@suaveplan/react-audio";
|
||||
|
||||
function TransportButtons() {
|
||||
const { play, pause, stop } = useTransport();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => play()}>Play</button>
|
||||
<button type="button" onClick={() => pause()}>Pause</button>
|
||||
<button type="button" onClick={() => stop()}>Stop</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTransport } from "@suaveplan/react-audio";
|
||||
|
||||
function TempoSlider() {
|
||||
const { setTempo, transport } = useTransport();
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={40}
|
||||
max={200}
|
||||
defaultValue={transport.bpm.getValueAtTime(transport.seconds)}
|
||||
onChange={(e) => setTempo(Number(e.currentTarget.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransport(): UseTransportResult`.
|
||||
- `UseTransportResult = { transport, play, pause, stop, seek, setTempo }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`seek` works by stopping the transport and (if it was playing)
|
||||
restarting it at the requested tick offset; this matches the
|
||||
underlying `Transport` API, which does not expose a direct seek
|
||||
primitive. `setTempo` writes the bpm `Signal` at the current
|
||||
clock — the signal owns its own ramp/automation logic, so consumers
|
||||
should use the `Signal` API for ramped tempo changes instead of
|
||||
calling `setTempo` rapidly. The callbacks are wrapped in
|
||||
`useCallback` keyed on `transport` so callers can memoise downstream
|
||||
work that depends on transport-callback identity.
|
||||
67
docs/ui/react/react-audio/src/provider/audio-context.md
Normal file
67
docs/ui/react/react-audio/src/provider/audio-context.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# `provider/audio-context`
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the React context object (`AudioReactContext`) that
|
||||
`<AudioProvider>` populates and that every hook reads from. The
|
||||
context value carries the engine state, the engine-state lifecycle
|
||||
phase (`pending` / `ready` / `error`), any async-init error, and a
|
||||
monotonically increasing `version` counter that consumers use to gate
|
||||
re-renders. Splitting the context declaration out from the provider
|
||||
component keeps the hook layer free of a circular dependency back into
|
||||
the provider's React tree code.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioReactContext` — `createContext<AudioContextValue | null>(null)`.
|
||||
- `ProviderState` — frozen-shape published engine bundle.
|
||||
- `AudioContextValue` — `{ state, engineState, error, version }`.
|
||||
- `EngineState` — `'pending' | 'ready' | 'error'`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useContext } from "react";
|
||||
import { AudioReactContext } from "@suaveplan/react-audio/provider";
|
||||
|
||||
function HookConsumer() {
|
||||
const ctx = useContext(AudioReactContext);
|
||||
if (ctx === null) {
|
||||
// no provider in tree
|
||||
}
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { useContext } from "react";
|
||||
import { AudioReactContext } from "@suaveplan/react-audio/provider";
|
||||
|
||||
function useEngineOrThrow() {
|
||||
const ctx = useContext(AudioReactContext);
|
||||
if (ctx?.state === null || ctx === null) {
|
||||
throw new Error("engine not ready");
|
||||
}
|
||||
return ctx.state;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioReactContext: React.Context<AudioContextValue | null>` — the
|
||||
shared context object.
|
||||
- `ProviderState` — engine bundle published by the provider.
|
||||
- `AudioContextValue` — provider's actual context value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`createContext(null)` is the canonical "no provider" sentinel; the
|
||||
hooks treat `useContext(...) === null` as the signal to throw a
|
||||
`REACT_AUDIO.NO_PROVIDER` error. `displayName` is set so React DevTools
|
||||
shows the context under a recognisable name. `ProviderState` is
|
||||
intentionally readonly across every field so a hook cannot mutate the
|
||||
engine bundle. The `version` counter exists so a consumer that wants
|
||||
to memoise on "engine identity" has a cheap integer key rather than
|
||||
holding a reference to the entire `ProviderState` object.
|
||||
89
docs/ui/react/react-audio/src/provider/audio-provider.md
Normal file
89
docs/ui/react/react-audio/src/provider/audio-provider.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# `provider/audio-provider`
|
||||
|
||||
## Purpose
|
||||
|
||||
`<AudioProvider>` is the single React component that owns the audio
|
||||
engine bundle for a React subtree. It constructs exactly one
|
||||
`BrowserTimelineAdapter`, one `TimelineEngine`, one `Transport`, one
|
||||
`ProviderBusRegistry`, and one `PluginRegistry`, publishes the
|
||||
resolved bundle through `AudioReactContext`, and tears every one of
|
||||
them down on unmount.
|
||||
|
||||
The provider is React 19 Strict Mode safe: under the development
|
||||
double-invoke pattern (mount → unmount → re-mount) it never leaks an
|
||||
engine. The async init pipeline is gated by a cancellation flag and an
|
||||
`AbortController`; if the effect tears down before init resolves the
|
||||
flag suppresses the publish step and any partially-constructed adapter
|
||||
is force-disposed immediately.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-mode-safe async init with `AbortSignal` cancellation.
|
||||
- Synchronous unmount path that disposes `adapter`, then `transport`,
|
||||
then `buses` — each at most once.
|
||||
- Zod-validated options (`AudioProviderOptions`) — invalid input
|
||||
throws `ReactAudioError(REACT_AUDIO.INVALID_OPTIONS)` at construct
|
||||
time.
|
||||
- Auto-registers consumer-supplied plugin factories into the package
|
||||
registry.
|
||||
- Publishes `engineState` (`'pending' | 'ready' | 'error'`) for the
|
||||
`useEngineState` hook and any consumer that wants to render a
|
||||
loading state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { AudioProvider } from "@suaveplan/react-audio";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<Workspace />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { AudioProvider } from "@suaveplan/react-audio";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AudioProvider
|
||||
options={{
|
||||
adapter: { sampleRate: 48000, latencyHint: "interactive" },
|
||||
transport: { bpm: 128 },
|
||||
plugins: [
|
||||
{ pluginType: "delay", factory: () => makeDelay() },
|
||||
],
|
||||
sampleRateHz: 30,
|
||||
}}
|
||||
>
|
||||
<Workspace />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<AudioProvider options? children?>` — renders a context provider.
|
||||
- `AudioProviderProps` — `{ options?: AudioProviderOptions, children?: ReactNode }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The async init pipeline lives entirely inside `useEffect`'s async
|
||||
IIFE so React's render path stays synchronous. A `PendingInit` value
|
||||
carries the in-flight adapter, transport, and bus-registry references
|
||||
so the cleanup callback can dispose anything already constructed if
|
||||
the effect is torn down mid-init — this is the linchpin of the
|
||||
Strict Mode guarantee. The `AbortController` is also passed to
|
||||
`adapter.init({ signal })` so the engine package's own cancellable
|
||||
work paths short-circuit. State updates (`setState`, `setEngineState`,
|
||||
`setVersion`) are skipped when `cancelled` is true so a torn-down
|
||||
effect cannot mutate React state. The teardown path is unconditional
|
||||
and synchronous: every dispose is wrapped in nothing but a null
|
||||
check, no try/catch — disposing a class that hasn't fully initialised
|
||||
is the responsibility of the owning package's `dispose()` to handle.
|
||||
70
docs/ui/react/react-audio/src/provider/chain-map.md
Normal file
70
docs/ui/react/react-audio/src/provider/chain-map.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# `provider/chain-map`
|
||||
|
||||
## Purpose
|
||||
|
||||
`ChainMap` is the provider-scoped lazy registry of `PluginChain`
|
||||
instances, keyed by track id. The hook `usePluginChain(trackId)` calls
|
||||
`getOrCreate(trackId)` to fetch (or create) the chain associated with
|
||||
that track, so two consumers asking for the same id see the same
|
||||
chain instance. The map's `dispose()` is wired into the provider's
|
||||
unmount path to dispose every chain it holds.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy creation: chains are constructed on first request, not
|
||||
eagerly at mount.
|
||||
- Identity stable for the same `trackId` across calls.
|
||||
- Configurable default channels / sampleRate / blockSize at
|
||||
construction; per-call overrides via `getOrCreate(id, config)`.
|
||||
- Inert after `dispose()` — further calls throw.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ChainMap, ProviderBusRegistry } from "@suaveplan/react-audio/provider";
|
||||
|
||||
const buses = new ProviderBusRegistry();
|
||||
const chains = new ChainMap(buses);
|
||||
|
||||
const chainA = chains.getOrCreate("track-a");
|
||||
const sameChainA = chains.getOrCreate("track-a"); // === chainA
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ChainMap, ProviderBusRegistry } from "@suaveplan/react-audio/provider";
|
||||
|
||||
const buses = new ProviderBusRegistry();
|
||||
const chains = new ChainMap(buses, {
|
||||
channels: 1,
|
||||
sampleRate: 44_100,
|
||||
blockSize: 256,
|
||||
});
|
||||
|
||||
// Per-track override:
|
||||
const monoChain = chains.getOrCreate("voice", { channels: 1 });
|
||||
const stereoChain = chains.getOrCreate("master", { channels: 2 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ChainMap(buses, defaultConfig?)`.
|
||||
- `getOrCreate(trackId, config?): PluginChain`.
|
||||
- `get(trackId): PluginChain | undefined`.
|
||||
- `list(): ReadonlyArray<string>`.
|
||||
- `dispose(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The map stores fully-constructed `PluginChain` instances; there is
|
||||
no proxy or wrapping. `dispose()` is idempotent and iterates the
|
||||
internal map's `values()`, calling `chain.dispose()` on each. After
|
||||
dispose the map is cleared so a late `get()` returns `undefined`
|
||||
and a late `getOrCreate()` throws. The Phase-1 default channels /
|
||||
sampleRate / blockSize line up with the typical browser audio context
|
||||
defaults; consumers who need different shapes pass them through
|
||||
`getOrCreate(id, config)` per chain. The `buses` argument is held by
|
||||
reference and passed into every chain — keeping a single bus registry
|
||||
for the whole provider subtree means sidechain wiring across tracks
|
||||
remains coherent.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `provider/provider-bus-registry`
|
||||
|
||||
## Purpose
|
||||
|
||||
`ProviderBusRegistry` is a thin wrapper around
|
||||
`DefaultBusRegistry` from `@suaveplan/audio-fx-plugin-base` that adds a
|
||||
synchronous `close()` method. The wrapper is owned by
|
||||
`<AudioProvider>` and torn down on unmount; closing it force-closes
|
||||
every still-active `SidechainPublisher` issued through this registry
|
||||
so no buses leak across remounts (especially relevant under React 19
|
||||
Strict Mode, which double-invokes effects).
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `BusRegistry` so it is a drop-in for the inner default.
|
||||
- Tracks every `SidechainPublisher` returned by `publish()` and
|
||||
force-closes them on `close()`.
|
||||
- Inert after close — late `publish()` calls return a no-op publisher
|
||||
rather than throwing.
|
||||
- Forwards `get`, `list`, `subscribe` to the inner registry untouched.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ProviderBusRegistry } from "@suaveplan/react-audio/provider";
|
||||
|
||||
const buses = new ProviderBusRegistry();
|
||||
const pub = buses.publish("sidechain", 1);
|
||||
pub.write([new Float32Array(128)]);
|
||||
|
||||
buses.close(); // closes pub; subsequent publish() returns a no-op.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ProviderBusRegistry } from "@suaveplan/react-audio/provider";
|
||||
|
||||
const buses = new ProviderBusRegistry();
|
||||
const pub = buses.publish("kick", 1);
|
||||
const off = buses.subscribe("kick", (bus) => {
|
||||
if (bus === undefined) console.log("bus closed");
|
||||
});
|
||||
|
||||
buses.close();
|
||||
off();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ProviderBusRegistry()` — no constructor arguments.
|
||||
- `publish(name, channels): SidechainPublisher` — tracked and
|
||||
closed on `close()`.
|
||||
- `get(name)`, `list()`, `subscribe(name, listener)` — delegate.
|
||||
- `close()` — idempotent; closes all tracked publishers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Wrapping rather than subclassing keeps the implementation independent
|
||||
of the inner class's private state. The `Set<SidechainPublisher>`
|
||||
tracks the *outer* (wrapped) publisher so closing it removes itself
|
||||
from the set before delegating to the inner close — this avoids
|
||||
double-iteration concerns when `close()` calls each publisher's close
|
||||
which would in turn try to mutate the set. Iterating via
|
||||
`Array.from(this.active)` snapshots the set so concurrent mutation
|
||||
during the close loop is safe. Once closed, `publish()` returns an
|
||||
inert publisher whose `write` / `close` are no-ops, allowing late
|
||||
consumers to drain without throwing.
|
||||
79
docs/ui/react/react-audio/src/provider/provider-options.md
Normal file
79
docs/ui/react/react-audio/src/provider/provider-options.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# `provider/provider-options`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `AudioProviderOptions` shape consumed by
|
||||
`<AudioProvider>`, the Zod schema that validates it, and the
|
||||
`parseAudioProviderOptions` helper that converts a Zod failure into a
|
||||
`ReactAudioError` whose code is `REACT_AUDIO.INVALID_OPTIONS`. The
|
||||
schema is intentionally permissive about deep adapter/transport
|
||||
internals — those are validated again by their owning packages
|
||||
(`@suaveplan/audio-engine-browser` revalidates `BrowserAdapterOptions`,
|
||||
`@suaveplan/audio-transport` revalidates transport options) — but
|
||||
strict about the provider-level shape so a typo such as `adaptor`
|
||||
fails at the React boundary instead of mid-way through async init.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioProviderOptionsSchema` — strict-object Zod schema rejecting
|
||||
unknown top-level keys.
|
||||
- `AudioProviderOptions` TypeScript interface mirroring the schema.
|
||||
- `parseAudioProviderOptions(input)` — single-call validator that
|
||||
throws `ReactAudioError(REACT_AUDIO.INVALID_OPTIONS)` on failure.
|
||||
- `PluginFactoryEntry` shape for the optional auto-registration list.
|
||||
- `PresetStore` shape for the pluggable preset persistence layer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseAudioProviderOptions } from "@suaveplan/react-audio/provider";
|
||||
|
||||
const options = parseAudioProviderOptions({
|
||||
adapter: { sampleRate: 48000 },
|
||||
transport: { bpm: 120 },
|
||||
sampleRateHz: 60,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseAudioProviderOptions,
|
||||
type AudioProviderOptions,
|
||||
type PluginFactoryEntry,
|
||||
} from "@suaveplan/react-audio/provider";
|
||||
|
||||
const factories: ReadonlyArray<PluginFactoryEntry> = [
|
||||
{ pluginType: "delay", factory: () => makeDelay() },
|
||||
{ pluginType: "reverb", factory: () => makeReverb() },
|
||||
];
|
||||
|
||||
const opts: AudioProviderOptions = {
|
||||
plugins: factories,
|
||||
sampleRateHz: 30, // half-rate sampling for low-power devices
|
||||
};
|
||||
|
||||
parseAudioProviderOptions(opts);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioProviderOptions` — `{ adapter?, transport?, plugins?,
|
||||
presetStore?, sampleRateHz?, slotRenderers? }`.
|
||||
- `PluginFactoryEntry` — `{ pluginType, factory }`.
|
||||
- `PresetStore` — `{ save(kind, id, payload), load(kind, id) }`.
|
||||
- `parseAudioProviderOptions(input)` — returns a frozen-shape value
|
||||
on success, throws `ReactAudioError` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is `.strict()` on the top-level object so unknown keys
|
||||
fail validation rather than being silently dropped — this catches
|
||||
shape regressions early. Deeper objects (`transport`, `presetStore`)
|
||||
use `.passthrough()` because their authoritative schemas live in the
|
||||
owning packages; revalidating their full shape here would couple this
|
||||
package to internal layouts that already validate at construction.
|
||||
`sampleRateHz` is clamped to `[1, 240]` to keep `requestAnimationFrame`
|
||||
sampling in a sensible band: below 1 the UI feels frozen, above 240
|
||||
the work done per frame outpaces typical display refresh.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `stores/audio-store`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAudioStore` is a slim provider-scoped Zustand store reserved for
|
||||
application-level audio UI overrides (display names, persisted user
|
||||
labels, …). It deliberately holds no playback state — playback lives
|
||||
on the `Transport`, and timeline state lives on the `TimelineEngine`.
|
||||
Duplicating either of those onto the store would create a second
|
||||
source of truth and a guaranteed staleness bug.
|
||||
|
||||
The store is exposed as a hook returning a typed state object with
|
||||
`overrides` plus three mutators (`setOverride`, `clearOverride`,
|
||||
`reset`). Consumers extend the shape by setting arbitrary keys into
|
||||
`overrides`; values are typed as `unknown` so the store does not
|
||||
constrain what gets stored.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure UI overrides — never engine state.
|
||||
- `setOverride` / `clearOverride` / `reset` mutators.
|
||||
- Identity-stable references for unchanged slices (Zustand
|
||||
default).
|
||||
- No middleware (no devtools, no persistence) — the store is
|
||||
intentionally minimal.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAudioStore } from "@suaveplan/react-audio";
|
||||
|
||||
function TrackLabel({ id }: { id: string }) {
|
||||
const label = useAudioStore((s) => s.overrides[`track-label:${id}`]) as
|
||||
| string
|
||||
| undefined;
|
||||
return <span>{label ?? id}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioStore } from "@suaveplan/react-audio";
|
||||
|
||||
function ResetOverridesButton() {
|
||||
const reset = useAudioStore((s) => s.reset);
|
||||
return (
|
||||
<button type="button" onClick={() => reset()}>
|
||||
Reset overrides
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAudioStore: UseBoundStore<StoreApi<AudioStoreState>>`.
|
||||
- `AudioStoreState = { overrides, setOverride, clearOverride, reset }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useAudioStore` is created with `create<AudioStoreState>(...)` — a
|
||||
single module-level instance shared across the React tree. The
|
||||
Phase-1 spec calls for a "provider-scoped store with override hook";
|
||||
Phase 2 (which lands the components) is the natural moment to scope
|
||||
the store to the provider's subtree via a `createContext`-backed
|
||||
factory. For Phase 1 a module-level store is sufficient because
|
||||
none of the shipped hooks read from it.
|
||||
78
docs/ui/react/react-audio/src/stores/ui-store/ui-store.md
Normal file
78
docs/ui/react/react-audio/src/stores/ui-store/ui-store.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# `stores/ui-store`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useUiStore` is the cross-component UI state store for the React
|
||||
audio workspace. It owns presentation-only state — which panels are
|
||||
open, the theme preference, the active visualization mode, the
|
||||
selected track id, and the live drag/drop state — none of which
|
||||
belong on the engine. Engine state (transport, timeline, plugin
|
||||
chain) lives on the audio classes and is read via hooks; the UI
|
||||
store never mirrors it.
|
||||
|
||||
The shape mirrors the upstream `react-audio` package so consumers
|
||||
migrating selectors across the package boundary do not need to
|
||||
rewrite them.
|
||||
|
||||
## Features
|
||||
|
||||
- `panelOpen: Record<string, boolean>` plus
|
||||
`setPanelOpen(panelId, open)`.
|
||||
- `theme: 'light' | 'dark' | 'auto'` plus `setTheme`.
|
||||
- `visualization: 'waveform' | 'spectrum' | 'meter' | 'off'` plus
|
||||
`setVisualization`.
|
||||
- `selectedTrackId: string | undefined` plus `setSelectedTrack`.
|
||||
- `dragState` plus `setDragState` / `clearDragState`.
|
||||
- `reset()` returns the store to the module-level initial value.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useUiStore } from "@suaveplan/react-audio";
|
||||
|
||||
function ThemeToggle() {
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const setTheme = useUiStore((s) => s.setTheme);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
Theme: {theme}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useUiStore } from "@suaveplan/react-audio";
|
||||
|
||||
function PluginDragOverlay() {
|
||||
const dragState = useUiStore((s) => s.dragState);
|
||||
if (dragState.kind !== "plugin") return null;
|
||||
return <div className="drag-ghost">{dragState.sourceId}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useUiStore: UseBoundStore<StoreApi<UiStoreState>>`.
|
||||
- `UiStoreState` — see source for full shape.
|
||||
- `VisualizationMode`, `ThemePreference`, `DragState` — exported
|
||||
type aliases.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The initial value is a module-level constant (`INITIAL`) so
|
||||
`reset()` is O(1) and identity-stable across calls. The
|
||||
`dragState` default (`NO_DRAG`) is `Object.freeze`d to guarantee
|
||||
consumers don't mutate it in place — Zustand uses reference equality
|
||||
for selector memoisation, and a mutated frozen object would silently
|
||||
break that. No middleware is attached; consumers wanting devtools or
|
||||
persistence wrap the store in their own factory.
|
||||
64
docs/ui/react/react-audio/src/test-utils.md
Normal file
64
docs/ui/react/react-audio/src/test-utils.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# `test-utils`
|
||||
|
||||
## Purpose
|
||||
|
||||
Test-only helpers for the `@suaveplan/react-audio` test suite. The
|
||||
module exports three utilities: `makeFakeAudioContext`, an injectable
|
||||
`BaseAudioContext`-shaped stub used by adapter tests; `makeFakePlugin`,
|
||||
a minimal `AudioFxPlugin` implementation with two declared parameters
|
||||
used by parameter / chain hook tests; and `renderWithProvider`, a
|
||||
`@testing-library/react` wrapper that mounts the component under an
|
||||
`<AudioProvider>` configured with the fake context.
|
||||
|
||||
The helpers are excluded from coverage and from the production build —
|
||||
they exist purely to keep the test files concise and the assertions
|
||||
focused on behaviour rather than fixture setup.
|
||||
|
||||
## Features
|
||||
|
||||
- `makeFakeAudioContext(sampleRate?)` — returns a plain object that
|
||||
satisfies the engine's `AdapterContextLike` and `AudioContextLike`
|
||||
structural interfaces, with every node type stubbed to no-ops.
|
||||
- `makeFakePlugin(options?)` — returns a minimal `AudioFxPlugin`
|
||||
with two parameters (`gain`, `tone`) and a working
|
||||
`getParameter`/`setParameter` pair.
|
||||
- `renderWithProvider(ui, opts?, rtlOpts?)` — renders `ui` inside an
|
||||
`<AudioProvider>` injected with the fake context; `opts.strict`
|
||||
toggles `<StrictMode>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { renderWithProvider } from "../test-utils.js";
|
||||
|
||||
renderWithProvider(<Consumer />);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { makeFakePlugin, renderWithProvider } from "../test-utils.js";
|
||||
|
||||
const plugin = makeFakePlugin({ id: "fake-eq" });
|
||||
|
||||
renderWithProvider(<UsesPlugin plugin={plugin} />, {
|
||||
strict: true,
|
||||
options: { sampleRateHz: 30 },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `makeFakeAudioContext(sampleRate?): unknown`.
|
||||
- `makeFakePlugin(options?): AudioFxPlugin`.
|
||||
- `renderWithProvider(ui, opts?, rtlOptions?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The fake context is a plain object rather than a class because the
|
||||
engine's structural type checks pass without nominal identity; every
|
||||
`createXxx` method returns a tagged stub whose `constructor.name`
|
||||
matches the relevant `AudioNode` class so the engine's
|
||||
`classifyNode` helper resolves correctly. The fake plugin's `process`
|
||||
is a no-op — these tests do not exercise audio block processing,
|
||||
only the React-tier event/state surface around the engine.
|
||||
106
docs/ui/react/react-auth-whitelabel/README.md
Normal file
106
docs/ui/react/react-auth-whitelabel/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# @suaveplan/react-auth-whitelabel - White-label authentication React components with tenant branding wired through @suaveplan/react-ui runtime theme mutator
|
||||
|
||||
White-label authentication React components with tenant branding wired
|
||||
through the `@suaveplan/react-ui` runtime theme-token mutator.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [login-form](./src/components/login-form/login-form.md) — `<LoginForm>` is the white-label login surface consumed by every tenant in the stack. It renders a controlled e…
|
||||
- [login-form](./src/components/login-form/login-form.md) — `<LoginForm>` is the white-label login surface consumed by every tenant in the stack. It renders a controlled e…
|
||||
- [password-reset-form](./src/components/password-reset-form/password-reset-form.md) — `<PasswordResetForm>` is the white-label password-reset surface. It owns a single state machine across two modes — `req…
|
||||
- [password-reset-confirm](./src/components/password-reset-form/password-reset-confirm.md) — `PasswordResetConfirm` is the confirm-mode body of `<PasswordResetForm>` — the screen the user lands on from the email…
|
||||
- [password-reset-form](./src/components/password-reset-form/password-reset-form.md) — `<PasswordResetForm>` is the white-label password-reset surface. It owns a single state machine across two modes — `req…
|
||||
- [password-reset-request](./src/components/password-reset-form/password-reset-request.md) — `PasswordResetRequest` is the request-mode body of `<PasswordResetForm>` — the screen the user lands on when they click…
|
||||
- [password-reset-success](./src/components/password-reset-form/password-reset-success.md) — `PasswordResetSuccess` is the success state of `<PasswordResetForm>`, shared between the request and confirm modes. It…
|
||||
- [register-form](./src/components/register-form/register-form.md) — `<RegisterForm>` is the white-label self-registration surface. It renders the header (logo + title + subtitle), the inl…
|
||||
- [register-form-fields](./src/components/register-form/register-form-fields.md) — `RegisterFormFields` is the inner field block of `<RegisterForm>` — name (optional, optionally required), email, passwo…
|
||||
- [register-form-terms](./src/components/register-form/register-form-terms.md) — `RegisterFormTerms` is the terms-acceptance checkbox block of `<RegisterForm>`. It renders the acceptance checkbox, the…
|
||||
- [register-form](./src/components/register-form/register-form.md) — `<RegisterForm>` is the white-label self-registration surface. It renders the header (logo + title + subtitle), the inl…
|
||||
- [auth-context](./src/context/auth-context.md) — `auth-context.tsx` ships the `<AuthProvider>` React context provider together with the three hooks `useAuth`, `useUser`…
|
||||
- [auth-http](./src/context/auth-http.md) — `auth-http.ts` houses the three helpers every `AuthProvider` verb shares: `resolveFetch`, `resolveNavigate`, and `postJ…
|
||||
- [branding-context](./src/context/branding-context.md) — `branding-context.tsx` ships the `<BrandingProvider>` React context provider together with the hooks `useBranding`, `us…
|
||||
- [error-codes](./src/errors/error-codes.md) — `error-codes.ts` registers the canonical, stable error-code registry used by every error thrown from `@suaveplan/react-…
|
||||
- [react-auth-whitelabel-error](./src/errors/react-auth-whitelabel-error.md) — `ReactAuthWhitelabelError` is the canonical error class for every error thrown by `@suaveplan/react-auth-whitelabel`. I…
|
||||
- [types](./src/types/branding.schema.md) — `TenantBrandingSchema` is the Zod schema that validates every `TenantBranding` payload entering the package from an unt…
|
||||
- [branding.schema](./src/types/branding.schema.md) — `TenantBrandingSchema` is the Zod schema that validates every `TenantBranding` payload entering the package from an unt…
|
||||
- [utils](./src/utils/navigate-or-call.md) — `navigateOrCall(target)` is the canonical helper used by every form component in this package to act on a "URL or click…
|
||||
- [navigate-or-call](./src/utils/navigate-or-call.md) — `navigateOrCall(target)` is the canonical helper used by every form component in this package to act on a "URL or click…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What ships
|
||||
|
||||
- **Forms** — `LoginForm`, `RegisterForm`, `PasswordResetForm`
|
||||
- **Contexts** — `AuthProvider`, `BrandingProvider`
|
||||
- **Hooks** — `useAuth`, `useBranding`, `useIsAuthenticated`, `useLogo`,
|
||||
`useTenantBranding`, `useUser`
|
||||
- **Types** — `AuthConfig`, `AuthContextValue`, `AuthUser`,
|
||||
`BrandingContextValue`, `BrandingProviderProps`, `RegisterData`,
|
||||
`TenantBranding`, `TenantBrandingSchema`
|
||||
- **Errors** — `REACT_AUTH_WL_CODES` + `ReactAuthWhitelabelError`
|
||||
|
||||
## Quick start
|
||||
|
||||
`BrandingProvider` calls `useThemeMutator()` from
|
||||
`@suaveplan/react-ui/theme`. That hook MUST be invoked inside a
|
||||
`<ThemeProvider>`. Wrap your subtree like this:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider, lightTheme } from "@suaveplan/react-ui/theme";
|
||||
import {
|
||||
AuthProvider,
|
||||
BrandingProvider,
|
||||
LoginForm,
|
||||
} from "@suaveplan/react-auth-whitelabel";
|
||||
|
||||
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
|
||||
<BrandingProvider
|
||||
brandingEndpoint="/api/branding"
|
||||
tenantId={tenantSlug}
|
||||
>
|
||||
<AuthProvider config={{ authUrl: "/api/auth" }}>
|
||||
<LoginForm
|
||||
onForgotPassword={() => router.push("/forgot")}
|
||||
onRegister={() => router.push("/register")}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</BrandingProvider>
|
||||
</ThemeProvider>;
|
||||
```
|
||||
|
||||
On every branding change, `BrandingProvider` writes `--brand-primary`,
|
||||
`--brand-secondary`, `--brand-accent`, and `--brand-font` to
|
||||
`document.documentElement.style` via
|
||||
`useThemeMutator().setExtensionTokens(...)`. Cleanup is automatic on
|
||||
unmount.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/components/login-form/login-form.md`](./src/components/login-form/login-form.md)
|
||||
- [`src/components/register-form/register-form.md`](./src/components/register-form/register-form.md)
|
||||
- [`src/components/register-form/register-form-fields.md`](./src/components/register-form/register-form-fields.md)
|
||||
- [`src/components/register-form/register-form-terms.md`](./src/components/register-form/register-form-terms.md)
|
||||
- [`src/components/password-reset-form/password-reset-form.md`](./src/components/password-reset-form/password-reset-form.md)
|
||||
- [`src/components/password-reset-form/password-reset-request.md`](./src/components/password-reset-form/password-reset-request.md)
|
||||
- [`src/components/password-reset-form/password-reset-confirm.md`](./src/components/password-reset-form/password-reset-confirm.md)
|
||||
- [`src/components/password-reset-form/password-reset-success.md`](./src/components/password-reset-form/password-reset-success.md)
|
||||
- [`src/context/auth-context.md`](./src/context/auth-context.md)
|
||||
- [`src/context/auth-http.md`](./src/context/auth-http.md)
|
||||
- [`src/context/branding-context.md`](./src/context/branding-context.md)
|
||||
- [`src/types/branding.schema.md`](./src/types/branding.schema.md)
|
||||
- [`src/errors/error-codes.md`](./src/errors/error-codes.md)
|
||||
- [`src/errors/react-auth-whitelabel-error.md`](./src/errors/react-auth-whitelabel-error.md)
|
||||
|
||||
## Out of scope (Excluded)
|
||||
|
||||
- MFA / passkey / WebAuthn flows — deferred until `@suaveplan/webauthn`
|
||||
- `@suaveplan/otp` ship .
|
||||
- Magic-link / SMS OTP — depends on `@suaveplan/security-codes` /
|
||||
`@suaveplan/emailer` / `@suaveplan/sms`.
|
||||
- Better-Auth first-class adapter — overlaps with the concurrent
|
||||
`@suaveplan/react-auth` Wave 1.
|
||||
- Audit-log integration, password-strength meter, rate limiter, axe-core
|
||||
story gate — depend on packages not yet .
|
||||
- Bundle-size snapshot — repo-wide tooling not yet wired.
|
||||
- Playwright E2E specs — see `e2e/README.md`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user