first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

21
docs/ui/react/README.md Normal file
View File

@@ -0,0 +1,21 @@
# `react`
React hooks and components for audio, auth, and UI surfaces.
<!-- begin:auto-docs -->
## Packages in `react`
9 packages.
| Package | Description |
|---|---|
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
<!-- end:auto-docs -->

1
docs/ui/react/_intro.md Normal file
View File

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

View File

@@ -0,0 +1,91 @@
# @suaveplan/react-audio-fingerprint - React 19 surface for client-side audio fingerprinting (Shazam-style constellation algorithm) and identity matching. Consumes FFT/spectrogram primitives from @suaveplan/audio-analysis-spectrum so the audio family has a single FFT implementation. Universal fingerprint + matcher cores; React hook + accessible component on top.
React 19 surface for client-side audio fingerprinting and identity matching.
The package implements the classic Shazam-style constellation algorithm
(2D-peak detection + pair hashing + time-offset histogram matching) on top of
the FFT/spectrogram primitives from
[`@suaveplan/audio-analysis-spectrum`](../../../media/audio/audio-analysis-spectrum)
— there is exactly **one** FFT implementation across the audio family.
<!-- begin:auto-docs -->
## Documentation tree
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
- [test-setup](./src/test-setup.md) — This file is loaded by `vitest.config.ts` via `setupFiles` so every test in the package runs against a consistent jest-…
<!-- end:auto-docs -->
## Status
bootstrap landing — initial slice focused on:
- Six error codes via `defineCodes("REACT_AUDIO_FINGERPRINT", …)`.
- `peak-detector`, `constellation`, `audio-fingerprinter`.
- `fingerprint-matcher` (time-offset histogramming).
- `memory-corpus` (`Map`-backed, async-shaped, subscribable).
- `local-backend` (default `FingerprintBackend` running in-process).
- `useAudioFingerprint` hook (driver with abort + progress).
- `<FingerprintMatchDisplay>` component + stories.
Deferred to follow-up: `IndexedDbCorpus`, `<AudioFingerprintUploader>`,
`<FingerprintVisualizer>`, `python-backend`, `__benchmarks__`, integration +
purity + property + bundle-size tests.
## Quick Start
```tsx
import { decode } from "@suaveplan/audio-engine-browser/decode";
import {
LocalBackend,
MemoryCorpus,
FingerprintMatchDisplay,
useAudioFingerprint,
} from "@suaveplan/react-audio-fingerprint";
function MatchSurface({ file }: { file: File }) {
const [doc, setDoc] = useState<AudioDocument | undefined>(undefined);
useEffect(() => {
decode(file).then(setDoc);
}, [file]);
const { state, fingerprint } = useAudioFingerprint(doc);
return state === "done"
? <FingerprintMatchDisplay results={[]} />
: <progress />;
}
```
## Algorithm
`AudioFingerprinter` composes three deterministic stages:
1. **Spectrogram** (delegated to `@suaveplan/audio-analysis-spectrum`).
2. **2D peak detection** (`peak-detector`) — local-max sweep above the
`noiseFloor`, capped at `peaksPerFrame` per frame.
3. **Constellation pair hashing** (`constellation`) — 32-bit packed
`binA[12] | binB[12] | dt[8]` over each anchor's `fanout` next peaks
inside a `targetZoneSec` window.
Matching is identical to the original Wang (2003) paper: build a 10 ms-wide
histogram of `(candidateOffset − queryOffset)` per shared hash, return the
heaviest bin's count as the score and its midpoint as the alignment offset.
## License
MIT — see `LICENSE` at the repo root.

View File

@@ -0,0 +1,66 @@
# `local-backend` — Default in-process `FingerprintBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LocalBackend` is the default implementation of the `FingerprintBackend`
contract that hooks and components depend on. It composes `AudioFingerprinter`
and `FingerprintMatcher` so callers get a single facade with the same shape
the optional `PythonBackend` exposes from its gated subpath. The class owns
no state apart from its two collaborator instances.
## Features
- Single facade for `fingerprint(doc, opts?)` and `match(query, corpus)`.
- `AbortSignal` and progress passthrough to the underlying fingerprinter.
- Pure in-process: no I/O, no fetch, no server dependency.
- Matches the `FingerprintBackend` interface byte-for-byte so it can be
swapped at runtime via a hook prop.
- Constructor takes no arguments — defaults rule.
## Usage: Basic Example
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const fp = await backend.fingerprint(doc);
```
## Usage: Advanced Example
Inject the backend into the hook explicitly to keep the default exporting tree
shaken when a custom backend is in use:
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const controller = new AbortController();
const fp = await backend.fingerprint(doc, {
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
const results = await backend.match(fp, corpus);
```
## API Reference
- `class LocalBackend implements FingerprintBackend`.
- `fingerprint(doc, opts?): Promise<Fingerprint>`.
- `match(query, corpus): Promise<MatchResult[]>`.
## Implementation Notes
The class deliberately avoids exposing the underlying `AudioFingerprinter` and
`FingerprintMatcher` instances so consumers cannot mutate them after
construction — this preserves determinism and keeps the public surface stable
across versions. There is no caching layer here; consumers wishing to cache
fingerprint results between renders should do so at the hook layer, where the
relationship to the React commit phase is explicit. Errors from the
underlying components propagate unchanged; the backend deliberately does NOT
re-wrap them so the original `ReactAudioFingerprintError` code surfaces to the
caller.

View File

@@ -0,0 +1,67 @@
# `FingerprintMatchDisplay` — Ranked results list
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match`
as an accessible ranked list. Each row shows the result identifier, a
confidence bar whose width is proportional to the top score, and the recovered
time offset in seconds. The component is intentionally lightweight — it has
no fetching logic, no internal state, and no animation — so consumers can
compose it inside any layout without conflicts.
## Features
- Empty-state message via `<p role="status">`.
- Confidence bar with proper `role="progressbar"` and `aria-valuemin/max/now`.
- Optional `onSelect` callback renders the row as a `<button>` for keyboard
activation.
- Optional `renderTitle` to override the default `id` label.
- Accessible `aria-label` on the wrapping list (`"Match results"` default).
- Zero side-effects; pure rendering function.
## Usage: Basic Example
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay results={ranked} />;
```
## Usage: Advanced Example
Map IDs to friendlier titles and listen for selection:
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay
results={ranked}
label="Top matches in your library"
onSelect={(r) => navigateToTrack(r.id)}
renderTitle={(r) => library.titles[r.id] ?? r.id}
/>;
```
## API Reference
- `FingerprintMatchDisplayProps = { results, label?, onSelect?, renderTitle? }`.
- `results: ReadonlyArray<MatchResult>`.
- `label?: string` (default `"Match results"`).
- `onSelect?: (result: MatchResult) => void`.
- `renderTitle?: (result: MatchResult) => string`.
## Implementation Notes
The component never mutates `props.results`. The confidence bar's width
percentage is computed against the top score (`results[0].score`) so the best
match always reads 100% and the rest scale proportionally — this provides an
intuitive visual ranking regardless of the absolute hash counts. When
`onSelect` is supplied each row renders as a `<button type="button">` so the
default keyboard behaviour (Enter / Space) just works; without `onSelect` the
rows are plain `<li>` to avoid spurious focus stops. The empty state uses
`role="status"` (rather than rendering nothing) so assistive tech announces
"no matches" rather than silently leaving the user in the dark.

View File

@@ -0,0 +1,72 @@
# `memory-corpus` — In-memory `Map`-backed fingerprint corpus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryCorpus` is the default `Corpus` implementation: a `Map<string,
Fingerprint>` wrapped with a `Promise`-returning surface and a subscription
hook. It satisfies the matcher's `CorpusLike` contract and the React hook's
`subscribe` requirement, making it the simplest store you can plug into the
fingerprint pipeline. The asynchronous API matches the `IndexedDbCorpus`
follow-up so caller code does not need to branch on backing store.
## Features
- `add`, `remove`, `get`, `size`, `values`, `subscribe`.
- Sync work behind an async surface — no I/O cost in tests.
- Notifies subscribers on every successful mutation.
- Throws `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED` on invalid IDs.
- `subscribe(listener)` returns an unsubscribe function (React-friendly).
- Iteration order matches insertion order.
## Usage: Basic Example
```ts
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
await corpus.add("song-1", fingerprint);
console.log(corpus.size()); // 1
```
## Usage: Advanced Example
Subscribe to mutations from a `useSyncExternalStore` consumer and clean up on
unmount:
```ts
import { useSyncExternalStore } from "react";
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
function useCorpusSize(): number {
return useSyncExternalStore(
(cb) => corpus.subscribe(cb),
() => corpus.size(),
() => 0
);
}
```
## API Reference
- `class MemoryCorpus implements Corpus`.
- `add(id, fingerprint): Promise<void>`.
- `remove(id): Promise<void>`.
- `get(id): Promise<FingerprintCorpusEntry | undefined>`.
- `size(): number`.
- `values(): IterableIterator<FingerprintCorpusEntry>`.
- `subscribe(listener): () => void`.
## Implementation Notes
The listener set is a `Set` so subscribe/unsubscribe is O(1) and duplicate
registrations are de-duped. `remove` only notifies when the key actually
existed; this matches the React convention that no-op mutations should not
trigger a render. Empty-string IDs are rejected so consumers cannot create a
ghost entry that `get("")` would later return — the matcher relies on `id`
being a stable label. Iteration uses a generator so callers can `break` early
without paying for a full copy of the store.

View File

@@ -0,0 +1,89 @@
# `error-codes` — Stable error registry for `@suaveplan/react-audio-fingerprint`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares every error code that `@suaveplan/react-audio-fingerprint`
can throw. Each code is registered through `defineCodes` from
`@suaveplan/error`, producing namespaced strings (e.g.
`REACT_AUDIO_FINGERPRINT.DECODE_FAILED`) that downstream code, telemetry, and
i18n catalogues can branch on without depending on free-form messages. The
companion `ReactAudioFingerprintError` class extends `SuaveplanError`, carries
the code as a structural field, and round-trips through the error wire format
defined by the error package.
## Features
- Six explicit codes covering the full failure surface: decode, fingerprint,
match, corpus, abort, and option validation.
- Frozen metadata table keyed by code with `description` and `severity`.
- `ReactAudioFingerprintError` class with stable `category = "react-audio-fingerprint"`.
- Idempotent `registerReactAudioFingerprintErrorCodes()` hook for parity with
sibling packages.
- Strict TypeScript typing — codes are inferred from the literal tuple.
## Usage: Basic Example
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "@suaveplan/react-audio-fingerprint/error-codes";
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED,
"react-audio-fingerprint.decode.failed",
{ context: { mime: "audio/mp3" } }
);
```
## Usage: Advanced Example
Discriminate caught errors by code and surface a structured toast:
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
} from "@suaveplan/react-audio-fingerprint/error-codes";
try {
await fingerprint(source);
} catch (e) {
if (e instanceof ReactAudioFingerprintError) {
const meta = REACT_AUDIO_FINGERPRINT_ERROR_METADATA[e.code];
if (e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED) {
return; // user-driven cancel, no toast
}
toast.error(`${meta.severity}: ${meta.description}`);
}
throw e;
}
```
## API Reference
- `REACT_AUDIO_FINGERPRINT_CODES` — frozen object whose keys/values are the
six dotted code strings.
- `ReactAudioFingerprintErrorCode` — union of valid code strings.
- `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` — frozen lookup keyed by code.
- `ReactAudioFingerprintError extends SuaveplanError` — `code`, `messageKey`,
optional `cause` / `context`, `category: "react-audio-fingerprint"`.
- `registerReactAudioFingerprintErrorCodes()` — no-op registration hook for
symmetry with packages that defer metadata registration to runtime.
## Implementation Notes
The metadata table is `Object.freeze`d so consumers cannot mutate it. Every
code in `REACT_AUDIO_FINGERPRINT_CODES` MUST have a metadata entry; the test
suite asserts the two sets line up via `Object.keys`. The class sets
`this.name` to include the severity for friendlier debugger output without
mutating the underlying code, leaving structural equality intact. Registration
runs at module load via the side-effectless hook so importing the barrel is
sufficient to make codes discoverable from the central registry. No biome
suppression comments are present; the file passes biome's strict ruleset
unaided.

View File

@@ -0,0 +1,73 @@
# `audio-fingerprinter` — Spectrogram → peaks → constellation orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioFingerprinter` is the entry point that turns an `AudioDocument` into a
deterministic constellation fingerprint. It composes three pieces: the
spectrogram from `@suaveplan/audio-analysis-spectrum` (the ONLY FFT used by
this package), the local-peak detector defined alongside this file, and the
constellation pair-hasher. The class owns no DSP — every numerical step is
delegated, which keeps the fingerprint algorithm in one auditable file.
## Features
- Zod-validated options (`frameSize`, `hopSize`, `window`, `noiseFloor`,
`peaksPerFrame`, `neighbourhood`, `fanout`, `targetZoneSec`).
- Cooperative cancellation via `AbortSignal` checked at every pipeline stage.
- Monotonic progress callback (`{ fraction }`) reported at 0, 0.5, 0.8, 1.
- Wraps spectrogram failures in `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`
while preserving the underlying `cause`.
- Stable output: identical input bytes produce a byte-equal fingerprint.
- Returns the sample rate, hop, and frame size alongside the fingerprint so
the matcher / visualizer can reconstruct frame ↔ time arithmetic.
## Usage: Basic Example
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const fp = await new AudioFingerprinter().fingerprint(doc);
console.log(fp.fingerprint.length, "pairs");
```
## Usage: Advanced Example
Pass an `AbortSignal` and a progress callback while running a non-default
window:
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const controller = new AbortController();
const fp = await new AudioFingerprinter().fingerprint(doc, {
window: "blackman",
frameSize: 2048,
hopSize: 512,
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
```
## API Reference
- `class AudioFingerprinter`.
- `fingerprint(doc, opts?): Promise<FingerprintResult>`.
- `FingerprintResult = { fingerprint, sampleRate, hopSize, frameSize }`.
- `FingerprintOptionsSchema` — Zod schema; defaults `frameSize=4096`,
`hopSize=1024`, `window="hann"`, `targetZoneSec=5`.
- `signal?: AbortSignal`, `onProgress?: ({ fraction }) => void`.
## Implementation Notes
`targetZoneFrames` is derived from `targetZoneSec` using the document's actual
`sampleRate` and the configured `hopSize`. This keeps the target zone musically
consistent across rates (e.g. 16 kHz speech vs 48 kHz music). The class only
re-throws a `ReactAudioFingerprintError` when it can identify the original
failure as an abort; everything else becomes `FINGERPRINT_FAILED` with the
original error as `cause`. There is no caching — every call re-runs the
spectrogram. Callers wanting to fingerprint the same document twice should
memoise the result themselves.

View File

@@ -0,0 +1,69 @@
# `constellation` — Pair-hash construction over detected peaks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The constellation module converts a list of spectrogram peaks into a stable,
hashable fingerprint by pairing each anchor peak with several subsequent peaks
inside a fixed target zone. Each pair `(binA, binB, Δframes)` is packed into a
32-bit integer; the resulting `(hash, timeOffsetSec)` records form the
fingerprint that the matcher consumes. This is the classic Shazam construction
described by Wang (2003) without any modifications.
## Features
- 32-bit packed hash with explicit `binA[12] | binB[12] | dt[8]` layout.
- `packHash` / `unpackHash` round-trip helpers for diagnostics and tests.
- Configurable `fanout`, `targetZoneFrames`, `minDeltaFrames` via Zod schema.
- Deterministic output sorted by anchor `timeOffsetSec`.
- Empty-input safe (no peaks → empty fingerprint, no throw).
- Validates `hopSamples` / `sampleRate` and rejects non-positive grids with
`ReactAudioFingerprintError(INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 512, 48_000);
console.log(fp.length, fp[0]?.hash);
```
## Usage: Advanced Example
Tighten the target zone for short percussive clips:
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 256, 48_000, {
fanout: 8,
targetZoneFrames: 80,
minDeltaFrames: 2,
});
```
## API Reference
- `buildConstellation(peaks, hopSamples, sampleRate, opts?): Fingerprint`.
- `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`.
- `packHash(binA, binB, dt): number` — 32-bit pack.
- `unpackHash(h): { binA; binB; dt }` — inverse.
- `ConstellationOptionsSchema` — Zod schema with defaults `fanout=5`,
`targetZoneFrames=200`, `minDeltaFrames=1`.
## Implementation Notes
The Δt field is clamped to 8 bits, which limits the effective target zone to
255 hop-frames regardless of the schema value. At the default 512-sample hop
and 48 kHz sample rate this is ~2.72 seconds — well inside the classic
constellation target-zone width. Hashes are unsigned 32-bit integers; the
`>>> 0` at the end of `packHash` reinterprets the bitwise result, which is
otherwise a signed 32-bit integer in JavaScript's number type. Output sorting
makes equal-fingerprint comparison trivial (`JSON.stringify` round-trip is
enough for byte equality). Pair construction is O(`peaks × fanout`) because
each anchor stops emitting once the fanout cap is hit or the target zone is
exhausted.

View File

@@ -0,0 +1,67 @@
# `fingerprint-matcher` — Time-offset histogram match scoring
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FingerprintMatcher` scores a query fingerprint against a corpus of candidate
fingerprints using the classic Shazam time-offset histogramming technique. For
every hash that appears in both query and candidate, the matcher accumulates
`(candidateOffset − queryOffset)` into a 10 ms-wide histogram bucket; the
heaviest bucket count is the match score and its midpoint is the alignment
offset. Results sort by descending score, ready for ranked display.
## Features
- `match(query, corpus)` returns ranked `MatchResult[]`.
- `matchOne(query, candidate)` returns the best bin only — useful for unit
tests and direct pairwise comparison.
- Configurable `binWidthSec` (default 0.01 s).
- Hash-index pre-step keeps the cost at `O(|query| + |candidate|)` per pair.
- Wraps iteration-time corpus failures in
`REACT_AUDIO_FINGERPRINT.MATCH_FAILED`.
- Empty inputs return zero-scored results without throwing.
## Usage: Basic Example
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const ranked = new FingerprintMatcher().match(queryFp, corpus);
console.log(ranked[0]?.id, ranked[0]?.score);
```
## Usage: Advanced Example
Drive a tighter alignment window for short clips:
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const m = new FingerprintMatcher();
const one = m.matchOne(queryFp, candidateFp, { binWidthSec: 0.005 });
console.log(one.score, one.offsetSec, one.matchedHashCount);
```
## API Reference
- `class FingerprintMatcher`.
- `match(query, corpus, opts?): MatchResult[]`.
- `matchOne(query, candidate, opts?): MatchOneResult`.
- `MatchResult = { id, score, offsetSec, matchedHashCount }`.
- `CorpusLike = { values(): Iterable<{ id; fingerprint }> }`.
- `MatcherOptions = { binWidthSec?: number }` (default `0.01`).
## Implementation Notes
A candidate's hash table is built lazily on each call — caching is left to
callers because corpora are typically streamed. Hash indices use `Map<number,
number[]>` since duplicate hashes inside one fingerprint are common (the same
constellation pair can recur at different times). The histogram is also a
`Map<number, number>` because the bucket range is unbounded in principle.
Bucket midpoints (rather than left edges) keep the reported offset symmetric
around the true alignment. The matcher is pure — given the same inputs it
produces the same outputs — so callers can safely memoise results keyed by
`(queryFp, corpusVersion)`.

View File

@@ -0,0 +1,69 @@
# `peak-detector` — 2D spectrogram local-peak detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The peak detector consumes a spectrogram (a sequence of FFT magnitude frames
produced by `@suaveplan/audio-analysis-spectrum`) and returns the time-frequency
coordinates of cells that dominate their local neighbourhood. The output drives
constellation hashing, which is the core of every Shazam-style fingerprint
algorithm. The detector is deterministic — identical input produces a
byte-equal peak list — and is the only place in the package where raw
spectrogram cells are inspected.
## Features
- Configurable square `neighbourhood` radius (default 3).
- `noiseFloor` threshold (linear magnitude, default 1e-3 ≈ −60 dBFS).
- `peaksPerFrame` cap (default 5) — bounds memory and downstream hash count.
- Deterministic ordering: ascending frame, then descending magnitude.
- Zod-validated options with a `ReactAudioFingerprintError(INVALID_OPTIONS)` on
invalid input.
## Usage: Basic Example
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram);
console.log(peaks.length, peaks[0]);
```
## Usage: Advanced Example
Tighten the neighbourhood and lower the floor to catch quiet harmonics in a
chirp:
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram, {
neighbourhood: 2,
noiseFloor: 1e-5,
peaksPerFrame: 8,
});
```
## API Reference
- `detectPeaks(spectrogram, opts?): Peak[]` — runs the 2D local-max sweep.
- `Peak = { frame: number; bin: number; magnitude: number }`.
- `PeakDetectorOptionsSchema` — Zod schema; defaults shown above.
- `PeakDetectorOptionsInput` — pre-parse input shape (everything optional).
## Implementation Notes
The implementation uses a straightforward `O(F · B · N²)` sweep where `F` is
the frame count, `B` is the bin count, and `N` is the neighbourhood radius. The
constants in the spec (default `N=3`, `peaksPerFrame=5`, `bins≈1025` for a
2048-point FFT) keep the cost firmly in `Float32Array` index arithmetic — no
heap allocations inside the inner loop other than the per-frame `frameHits`
array, which is bounded by the bin count. Sorting by descending magnitude and
truncating to `peaksPerFrame` ensures the loudest local maxima win when more
candidates qualify than the per-frame cap allows; ties resolve to the lower
bin via the stable native sort. Empty spectrograms produce an empty array
without throwing; downstream constellation hashing handles that case by
emitting no pairs.

View File

@@ -0,0 +1,71 @@
# `useAudioFingerprint` — React hook for the fingerprint pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a
React-managed `AudioDocument` and returns the live state, the resulting
fingerprint when done, a `0..1` progress fraction, and any error encountered.
The hook centralises the abort-on-unmount and progress wiring so component
authors do not have to repeat that work across uploaders, visualisers, and
match displays.
## Features
- `source` can be `undefined`/`null` to mean "do nothing" — useful while
waiting for an upload to decode.
- `state` cycles through `'idle'` → `'fingerprinting'` → `'done'` (or
`'error'`); a fresh source resets the cycle.
- Abort-on-unmount and abort-on-source-change via internal `AbortController`.
- Backend swap via `options.backend`; defaults to `LocalBackend`.
- Wraps non-`ReactAudioFingerprintError` rejections in `FINGERPRINT_FAILED`.
- Suppresses `ABORTED` errors — they settle back to `'idle'`.
## Usage: Basic Example
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function FingerprintPreview({ doc }: { doc: AudioDocument }) {
const { state, fingerprint, progress } = useAudioFingerprint(doc);
if (state === "fingerprinting") return <progress value={progress} />;
if (state === "done") return <span>{fingerprint?.length} pairs</span>;
return null;
}
```
## Usage: Advanced Example
Inject a custom backend (e.g. the optional Python backend) and surface errors:
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function Surface({ doc, backend }: { doc: AudioDocument; backend: FingerprintBackend }) {
const { state, error, fingerprint } = useAudioFingerprint(doc, { backend });
if (state === "error") return <Banner code={error?.code}>{error?.message}</Banner>;
if (state === "done") return <Result fp={fingerprint!} />;
return <Spinner />;
}
```
## API Reference
- `useAudioFingerprint(source, options?): UseAudioFingerprintResult`.
- `source: AudioDocument | undefined | null`.
- `options?: { backend?: FingerprintBackend }`.
- `UseAudioFingerprintResult = { state, fingerprint, progress, error }`.
- `state: 'idle' | 'fingerprinting' | 'done' | 'error'`.
## Implementation Notes
The hook uses `useMemo` to stabilise the default `LocalBackend` so changing
unrelated props does not retrigger the effect. The `alive` flag guarding all
post-await `setState` calls is the canonical React pattern for protecting
against the lifecycle race between an async fetch completing and a fast
unmount. Aborts settle to `'idle'` (not `'error'`) so user-driven cancels do
not produce a banner. Non-Suaveplan errors get wrapped so consumers can always
discriminate via `error.code` without an `instanceof` chain.

View File

@@ -0,0 +1,58 @@
# `test-setup` — Vitest global setup
## Purpose
This file is loaded by `vitest.config.ts` via `setupFiles` so every test in
the package runs against a consistent jest-dom matcher set and tears down its
React tree after each case. It is intentionally short — heavy work happens in
the runner-agnostic polyfills shipped by `@suaveplan/testing/polyfills`, which
this file complements with DOM-specific affordances.
## Features
- Imports `@testing-library/jest-dom/vitest` to register matchers such as
`.toBeInTheDocument()` and `.toHaveAccessibleName()`.
- Registers an `afterEach` that calls `cleanup()` from
`@testing-library/react`, unmounting trees between tests and avoiding
cross-test DOM pollution.
- No mocks, no spies, no global stubs — keeps the suite faithful to the
CLAUDE.md "no mocks" rule.
## Usage: Basic Example
`vitest.config.ts` references this file:
```ts
setupFiles: [
"@suaveplan/testing/polyfills",
"./src/test-setup.ts",
],
```
## Usage: Advanced Example
If a future component needs an additional matcher (for example to assert
custom accessibility properties), add the relevant import here so every test
in the package benefits without per-file boilerplate:
```ts
import "@testing-library/jest-dom/vitest";
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
// (Add additional matchers or global afterEach hooks here.)
afterEach(() => cleanup());
```
## API Reference
- No exports; runs purely for side-effects at vitest setup time.
- Depends on `@suaveplan/testing/runner` for `afterEach` (rule 11 compliance).
## Implementation Notes
The setup file runs once per worker, so the `cleanup` afterEach registration
is shared across every test file in that worker. This is the canonical
pattern documented by Testing Library and matches every sibling Suaveplan
React package's setup. The package owns no provider configuration so there is
no provider-tree wrapper to install — tests render components directly via
`@testing-library/react`'s `render` / `renderHook`.

View File

@@ -0,0 +1,136 @@
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
Track-style media-player surface for the Suaveplan audio family.
The package ships two kinds of exports:
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
`<LyricsDisplay>` component implemented in this package.
2. **Re-export façade** — namespace and flat aliases for the player
UI that already lives in
[`@suaveplan/react-audio`](../react-audio/README.md):
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
`<PluginChainView>`, `<PresetManager>`, plus hooks
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
…).
Consumers can either import directly from `@suaveplan/react-audio`
(the original home of those components) or from
`@suaveplan/react-audio-player`, whichever reads better in their
codebase. The named entry points resolve to the same singletons via
ESM re-export — no duplication, no extra bundle weight.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
<!-- end:auto-docs -->
## Status
This package targets the *additive* scope of the
`enterprise-react-audio-player` change. The pre-existing player
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
visualisers, transport controls, mixer, preset manager) already live
in `@suaveplan/react-audio`; duplicating them here would violate the
"do not duplicate enterprise components" guidance. Instead this
package adds:
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
for the most common entry points.
- Stable `REACT_AUDIO_PLAYER.*` error codes.
### Deferred scope
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
`@suaveplan/react-audio-stream` exists . The spec
requirement is retained in the canonical capability spec with a
`> Deferred (multi-peer)` marker; once the dependency lands, the
hook adds in a follow-up change without breaking this package's
public surface.
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
requirement is retained; consumers needing these today can
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
optional `./screensavers/*` subpaths will be filled by a focused
follow-up change.
The two hooks consumed by visualisers (`useAnalyser`) and queues
(`usePlaylist`) **are** shipped — they were added to
`@suaveplan/react-audio` alongside this package and are re-exported
here for ergonomic reach.
## Quick start — karaoke
```tsx
import {
AudioProvider,
AudioPlayer,
LyricsDisplay,
} from "@suaveplan/react-audio-player";
function App({ track, lrc }: { track: string; lrc: string }) {
return (
<AudioProvider options={{ /* … */ }}>
<AudioPlayer source={track} />
<KaraokeLine lrc={lrc} />
</AudioProvider>
);
}
import { useTransportTime } from "@suaveplan/react-audio";
function KaraokeLine({ lrc }: { lrc: string }) {
const seconds = useTransportTime();
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
}
```
## Quick start — standalone preview (no provider)
```tsx
import {
parseLrc,
LyricsDisplay,
} from "@suaveplan/react-audio-player";
import { useMemo, useState } from "react";
export function Preview({ lrc }: { lrc: string }) {
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
const [t, setT] = useState(0);
return (
<div>
<input
type="range"
min={0}
max={120}
step={0.1}
value={t}
onChange={(e) => setT(Number(e.currentTarget.value))}
aria-label="time"
/>
<LyricsDisplay lyrics={lyrics} currentSec={t} />
</div>
);
}
```
## Subpath exports
| Subpath | Surface |
| --- | --- |
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
| `./karaoke` | All karaoke exports. |
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
| `./karaoke/use-lyrics` | `useLyrics` and types. |
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
See the per-feature `.md` files for full API references.

View File

@@ -0,0 +1,98 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/react-audio-player`.
Every error thrown by a public surface in this package carries a
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
resolution, and an optional `context` / `cause` chain. Codes are
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
stable across versions.
The registry is the single source of truth for the package's error
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
and confirms every code is reachable from a real call site, so a
code that gets added without a thrower (or a thrower whose code
gets removed) is a build break.
## Features
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
- Frozen metadata map keyed by code; every entry has a `description`
and `severity` (`"error"` | `"warning"`).
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
stable `category = "react-audio-player"` field.
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
sibling audio packages.
## Usage: Basic
```ts
import {
REACT_AUDIO_PLAYER_CODES,
ReactAudioPlayerError,
} from "@suaveplan/react-audio-player/error-codes";
if (!provider) {
throw new ReactAudioPlayerError(
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
"react-audio-player.no-provider"
);
}
```
## Usage: Advanced
```ts
import {
REACT_AUDIO_PLAYER_CODES,
REACT_AUDIO_PLAYER_ERROR_METADATA,
ReactAudioPlayerError,
} from "@suaveplan/react-audio-player/error-codes";
try {
parseLrc(input);
} catch (cause) {
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
cause,
context: { severity: meta.severity, bytes: input.length },
});
}
```
## API Reference
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
five public codes (frozen).
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
## Implementation Notes
`defineCodes` is the workspace's standard helper for producing
namespaced code maps; it freezes the result and asserts that every
key is a valid identifier at module load. Metadata is declared with
`Object.freeze` so consumers (and the test suite) cannot mutate it
at runtime.
`SuaveplanError` is the universal base error from
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
`cause` plus a stable wire-format serialisation. The
`category = "react-audio-player"` literal is the discriminator used
by the cross-package error router when an error crosses a package
boundary.
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
output includes the severity at a glance. This is a string-only
augmentation; it does not change the prototype chain so
`instanceof ReactAudioPlayerError` still narrows correctly.

View File

@@ -0,0 +1,106 @@
# `karaoke/lrc-parser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
karaoke source into a timestamp-sorted array of `Lyric` entries
suitable for the package's `<LyricsDisplay>` and any consumer-built
karaoke surface. The parser is universal — it has no React, DOM, or
audio-context dependency — so it is safe to call in workers, on the
server during SSR, or inside other Suaveplan tools that need to
preview lyric timing.
The companion `activeLyricIndex(lyrics, currentSec)` performs a
binary search for the active line and is the bridge between the
transport's `seconds` and the `<LyricsDisplay>` highlight state.
Both functions are pure; the parser throws structured
`ReactAudioPlayerError` for the two invalid-input cases.
## Features
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
`[h:mm:ss(.xxx)]` timestamp forms.
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
togglable via `expandMultiTimestamp`.
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
- Skips empty lines, malformed lines, and lines with no leading
timestamp.
- Output is sorted ascending by `timestampSec` and frozen.
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
or input exceeding `maxLength` (default 1 MiB).
- `activeLyricIndex` is O(log n) and allocation-free.
## Usage: Basic
```ts
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
const lyrics = parseLrc(`
[ar:Test Artist]
[00:10.50]First line
[00:25.00]Second line
`);
// → [{ timestampSec: 10.5, text: "First line" },
// { timestampSec: 25.0, text: "Second line" }]
```
## Usage: Advanced
```tsx
import {
parseLrc,
activeLyricIndex,
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
import { useTransportTime } from "@suaveplan/react-audio";
import { useMemo } from "react";
function LyricLine({ source }: { source: string }) {
const lyrics = useMemo(
() => parseLrc(source, { expandMultiTimestamp: false }),
[source]
);
const seconds = useTransportTime();
const idx = activeLyricIndex(lyrics, seconds);
return (
<span aria-live="polite">
{idx >= 0 ? lyrics[idx].text : "…"}
</span>
);
}
```
## API Reference
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
- `Lyric` — `{ timestampSec: number; text: string }`.
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
`expandMultiTimestamp?: boolean` (default `true`).
## Implementation Notes
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
captures optional hours, minutes, seconds, and an optional
fractional component. The fractional value is padded right with
zeros to three digits then divided by 1000, so `.5` parses to 0.500
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
fractional separator; the `:` form would conflict with the
`[h:mm:ss]` extended timestamp grammar.
Multi-timestamp expansion walks the regex's global state, but only
honours timestamps that form a contiguous prefix of the line. A
bracketed sequence later in the line is treated as text — many
karaoke services emit `[00:10]hello [00:11]world` to mean two
separate lyric lines and this parser handles both layouts
correctly.
`activeLyricIndex` uses the upper-bound binary search idiom: pick
the greatest index whose timestamp is ≤ current. When `current` is
between two entries, the earlier entry wins; when `current`
precedes the first entry, `-1` is returned (so the consumer can
render an empty active line until the first cue).

View File

@@ -0,0 +1,109 @@
# `karaoke/lyrics-display`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LyricsDisplay>` is the karaoke surface that renders a stack of
lyric lines with the active line highlighted. It is the visible
end-product of the `parseLrc` → `useLyrics` pipeline and the
component the player variants compose when their `lrc` prop is
populated.
The component is clock-agnostic by design: the caller passes
`currentSec` (a plain number) and the component renders the
highlighted line. The typical wiring is `useTransportTime()` from
`@suaveplan/react-audio` when the display sits inside an
`<AudioProvider>`, but standalone preview and test surfaces can
hand in any number — Playwright spec runs use a deterministic
clock; documentation pages drive a `<range>` input. Keeping the time
source explicit removes a hidden provider dependency.
## Features
- Renders one `<span>` per lyric line with `data-active="true|false"`
for CSS-driven styling; consumers needing structural control can
pass `renderLine` for full custom rendering.
- Active line carries `aria-live="polite"` by default so screen
readers announce lyric changes; togglable via `announceLyrics`.
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
- Re-binds the active index every 100 ms (configurable) regardless
of the consumer's render cadence.
- Both time-source modes share the same internal render path; the
outer dispatch is a one-time component-type pick.
## Usage: Basic
```tsx
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
import { useTransportTime } from "@suaveplan/react-audio";
function Player({ lrcSource }: { lrcSource: string }) {
const seconds = useTransportTime();
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
}
```
## Usage: Advanced
```tsx
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
import { useMemo } from "react";
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
return (
<LyricsDisplay
lyrics={lyrics}
currentSec={time}
intervalMs={50}
announceLyrics={false}
renderLine={({ lyric, isActive }) => (
<p
style={{
opacity: isActive ? 1 : 0.4,
fontWeight: isActive ? 700 : 400,
}}
>
{lyric.text || <span aria-hidden="true">·</span>}
</p>
)}
/>
);
}
```
## API Reference
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
- `lrc?: string` — parsed once per identity change.
- `currentSec: number` — required.
- `intervalMs?: number` (default 100).
- `announceLyrics?: boolean` (default `true`).
- `className?: string`.
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
## Implementation Notes
The component is a single-branch render — no dual-component
dispatch — because `currentSec` is always a plain prop. This keeps
the Rules of Hooks trivially satisfied (one render path, one hook
sequence) and removes a hidden coupling to the surrounding
`<AudioProvider>`. Consumers that want the transport-driven path
call `useTransportTime()` themselves and pass the result down.
The `data-active="true|false"` attribute is the recommended CSS
selector hook. Toggling `aria-live` only on the active line ensures
exactly one element narrates per change cycle; without that targeted
gating, every line's render would re-announce on every re-mount,
which is acoustically unusable.
When `renderLine` is supplied, the component delegates to it
verbatim and skips the default `<span>` + `data-active` path. The
delegated node is wrapped in a keyed `<Fragment>` so consumers can
return any element they like — `<li>`, `<p>`, `<div>` — without
fighting an enclosing wrapper.

View File

@@ -0,0 +1,121 @@
# `karaoke/use-lyrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the
active-line state, samples it at a fixed interval (100 ms by
default), and returns `{ lyrics, activeIndex, activeLyric }`. The
consumer hands in either pre-parsed `Lyric[]` or raw LRC source,
plus the current playback time in seconds. The hook deliberately
does not call `useTransportTime` itself so it can be driven from any
clock — `<LyricsDisplay>` wires the transport in for you, but a
preview panel running under a deterministic test clock can call this
hook directly.
The 100 ms re-bind ceiling matches the spec requirement that the
active lyric advance within 100 ms of the transport. Sampling at
that cadence (rather than per-frame) saves a substantial amount of
React commit work on songs with hundreds of lyric lines.
## Features
- Pure: no audio context, no global side-effect.
- Pre-parsed `lyrics` or raw `lrc` source — both accepted; if both
provided, `lyrics` wins (no double work).
- Configurable `intervalMs` (default 100). The hook re-evaluates
the active line on each tick and only commits when the index
actually changes — silent renders cost nothing.
- Returns `activeLyric: undefined` before the first cue or when
`lyrics` is empty.
## Usage: Basic
```tsx
import { useLyrics } from "@suaveplan/react-audio-player/karaoke/use-lyrics";
import { useTransportTime } from "@suaveplan/react-audio";
function Lyrics({ lrc }: { lrc: string }) {
const seconds = useTransportTime();
const { activeLyric } = useLyrics({ lrc, currentSec: seconds });
return <span aria-live="polite">{activeLyric?.text ?? "…"}</span>;
}
```
## Usage: Advanced
```tsx
import {
parseLrc,
useLyrics,
} from "@suaveplan/react-audio-player/karaoke";
import { useMemo, useState } from "react";
function StandaloneLyricPreview({ lrc }: { lrc: string }) {
// Parse once at the call site rather than on every mount.
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
// Deterministic clock — no <AudioProvider> required.
const [tick, setTick] = useState(0);
const { activeIndex, activeLyric, lyrics: ls } = useLyrics({
lyrics,
currentSec: tick * 0.5,
intervalMs: 50,
});
return (
<div>
<button type="button" onClick={() => setTick((t) => t + 1)}>
+0.5 s
</button>
<ol>
{ls.map((l, i) => (
<li
key={`${l.timestampSec}-${i}`}
data-active={i === activeIndex}
>
{l.text}
</li>
))}
</ol>
<p>Active: {activeLyric?.text ?? "—"}</p>
</div>
);
}
```
## API Reference
- `useLyrics(opts: UseLyricsOptions): UseLyricsResult`
- `UseLyricsOptions`:
- `lyrics?: readonly Lyric[]` — preferred.
- `lrc?: string` — parsed once per identity change.
- `intervalMs?: number` (default 100).
- `currentSec: number` — required.
- `UseLyricsResult`:
- `lyrics: readonly Lyric[]`,
- `activeIndex: number` (`-1` for "before first cue / empty"),
- `activeLyric: Lyric | undefined`.
## Implementation Notes
The hook owns one piece of React state — the active index — and one
`setInterval`. Both `lyrics` and `currentSec` are mirrored through
refs so the interval callback always sees the latest values without
re-binding the timer on every render. The interval only fires
`setActiveIndex` when the recomputed index actually differs, which
is the standard React micro-optimisation for high-frequency external
clocks.
`activeLyricIndex` (from the sibling `lrc-parser` module) is a
binary search; combined with the 100 ms sampling cadence, the hook's
steady-state work per frame is one comparison plus a "same index?"
branch. Long-form lyrics (10k+ lines) are not measurably more
expensive than short ones.
The hook lazily initialises its `activeIndex` state from
`activeLyricIndex(lyrics, currentSec)` so the first render — before
the effect mounts — already reflects the correct active line. This
matters for SSR-rendered previews and snapshot tests that read the
hook's output without ever ticking the interval.

View File

@@ -0,0 +1,132 @@
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
React 19 surface for live audio streaming sessions: a Provider, six hooks, and four
accessible components. Chunk C of the four-chunk `enterprise-react-audio-stream` split.
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
> consumers actually touch.
<!-- begin:auto-docs -->
## Documentation tree
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
<!-- end:auto-docs -->
## Provider
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
`StreamSession` lazily; fans seven session event channels into a single render-bump
counter; disconnects on unmount.
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
## Hooks
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
`disconnect`.
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
- `useStreamPeers()` — live `readonly Peer[]`.
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
`setTrackSolo`.
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
`RecordingResult` blob.
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
## Components
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
leave callbacks.
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
read-out.
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
nothing when permission is granted.
## Quick start
```tsx
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
function Room() {
const { state, connect, join } = useStreamSession();
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
return <p>Joined!</p>;
}
export function App() {
return (
<StreamProvider sessionFactory={() => createMyStreamSession()}>
<Room />
</StreamProvider>
);
}
```
## Constraints (from the canonical spec)
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
- DAW solo semantics live in the mixer hook, not in each component.
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.

View File

@@ -0,0 +1,67 @@
# SharedTransportIndicator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
## Features
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
- Optional `label` override (string or any `ReactNode`).
- Optional `className` for layout integration.
- Spec-aligned default label: `"Shared transport active"`.
## Usage: Basic
```tsx
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
function StatusBar(props: HostProps) {
const share = useTransportShare(props);
return <SharedTransportIndicator state={share.state} />;
}
```
## Usage: Advanced
```tsx
function MixerHeader(props: HostProps) {
const share = useTransportShare(props);
return (
<header className="mixer-header">
<SharedTransportIndicator
state={share.state}
className="mixer-header__share-badge"
visibleRoles={["driving", "receiving"]}
label={
share.state.role === "driving"
? `Driving ${share.state.receivers.length} peer(s)`
: `Receiving from ${share.state.driverId}`
}
/>
</header>
);
}
```
## API Reference
- `SharedTransportIndicator(props): ReactNode`
- `SharedTransportIndicatorProps`
- `state: ShareState` — required.
- `className?: string` — optional root class.
- `label?: ReactNode` — optional label override.
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
## Implementation Notes
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.

View File

@@ -0,0 +1,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).

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

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

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

View 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