Initial Commit

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

View File

@@ -0,0 +1,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,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { formatAriaValueText } from "./aria-value-text.js";
describe("aria-value-text", () => {
it("signed dB formatter", { timeout: 5000 }, () => {
expect(formatAriaValueText(3.5, "dB", -12, 12)).toBe("+3.50 dB");
expect(formatAriaValueText(-3.5, "dB", -12, 12)).toBe("-3.50 dB");
expect(formatAriaValueText(0, "dB", -12, 12)).toBe("0.00 dB");
expect(
formatAriaValueText(3.5, "dB", -12, 12, { signedDb: false })
).toBe("3.50 dB");
});
it("Hz auto-promotes to kHz above 1000", { timeout: 5000 }, () => {
expect(formatAriaValueText(880, "Hz", 20, 20000)).toBe("880 Hz");
expect(formatAriaValueText(2400, "Hz", 20, 20000)).toBe("2.40 kHz");
});
it("percent suffix", { timeout: 5000 }, () => {
expect(formatAriaValueText(50, "%", 0, 100)).toBe("50.0 %");
});
it("various units render", { timeout: 5000 }, () => {
expect(formatAriaValueText(0.5, "ms", 0, 1)).toMatch(/ms$/);
expect(formatAriaValueText(0.5, "s", 0, 1)).toMatch(/ s$/);
expect(formatAriaValueText(2, "ratio", 1, 10)).toMatch(/: 1$/);
expect(formatAriaValueText(10, "cents", 0, 100)).toMatch(/cents$/);
expect(formatAriaValueText(7, "semitones", 0, 12)).toMatch(/st$/);
expect(formatAriaValueText(128, "samples", 0, 1024)).toMatch(
/samples$/
);
expect(formatAriaValueText(120, "bpm", 40, 200)).toMatch(/BPM$/);
expect(formatAriaValueText(1, "kHz", 0, 20)).toMatch(/kHz$/);
expect(formatAriaValueText(1, "custom-unit", 0, 10)).toMatch(
/custom-unit$/
);
});
it("out-of-range / NaN safe", { timeout: 5000 }, () => {
expect(formatAriaValueText(Number.NaN, "dB", -1, 1)).toBe(
"out of range"
);
expect(formatAriaValueText(Number.POSITIVE_INFINITY, "Hz", 0, 1)).toBe(
"out of range"
);
});
it("overridable fractionDigits", { timeout: 5000 }, () => {
expect(
formatAriaValueText(0.123456, "%", 0, 100, { fractionDigits: 4 })
).toBe("0.1235 %");
});
it("auto-precision tiers", { timeout: 5000 }, () => {
expect(formatAriaValueText(0.5, "Hz", 0, 0.999)).toMatch(/0\.500/);
expect(formatAriaValueText(50, "Hz", 0, 100)).toMatch(/50\.0/);
expect(formatAriaValueText(500, "Hz", 0, 999)).toMatch(/500\.0/);
expect(formatAriaValueText(2400, "Hz", 0, 20000)).toMatch(/kHz/);
expect(formatAriaValueText(50, "Hz", 0, 5000)).toMatch(/50 Hz/);
});
});

View File

@@ -0,0 +1,81 @@
/**
* Unit-aware `aria-valuetext` formatter consumed by Phase-2
* parameter controls. Pure function — no React, no DOM. Formats a
* numeric value with the parameter's unit suffix and a sensible
* precision derived from the value's magnitude and the parameter's
* range. Special-cases dB (signed) and percentage units.
*/
export type AriaValueUnit =
| "dB"
| "Hz"
| "kHz"
| "ms"
| "s"
| "%"
| "ratio"
| "cents"
| "semitones"
| "samples"
| "bpm"
| string;
export interface FormatAriaValueOptions {
/** Override the auto-derived fractional digits. */
readonly fractionDigits?: number;
/** When true, prefix positive dB with "+" (default true). */
readonly signedDb?: boolean;
}
/**
* Returns a screen-reader-friendly string for the given value and
* unit. The number of fractional digits is chosen automatically
* from the value's magnitude and the parameter's range when not
* overridden.
*/
export function formatAriaValueText(
value: number,
unit: AriaValueUnit,
min: number,
max: number,
options: FormatAriaValueOptions = {}
): string {
if (!Number.isFinite(value)) {
return "out of range";
}
const range = Math.abs(max - min);
const digits =
options.fractionDigits ??
(range >= 1000 ? 0 : range >= 100 ? 1 : range >= 1 ? 2 : 3);
switch (unit) {
case "dB": {
const sign = options.signedDb === false ? "" : value > 0 ? "+" : "";
return `${sign}${value.toFixed(digits)} dB`;
}
case "%":
return `${value.toFixed(digits)} %`;
case "Hz":
if (Math.abs(value) >= 1000) {
return `${(value / 1000).toFixed(2)} kHz`;
}
return `${value.toFixed(digits)} Hz`;
case "kHz":
return `${value.toFixed(digits)} kHz`;
case "ms":
return `${value.toFixed(digits)} ms`;
case "s":
return `${value.toFixed(digits)} s`;
case "ratio":
return `${value.toFixed(digits)} : 1`;
case "cents":
return `${value.toFixed(digits)} cents`;
case "semitones":
return `${value.toFixed(digits)} st`;
case "samples":
return `${value.toFixed(0)} samples`;
case "bpm":
return `${value.toFixed(digits)} BPM`;
default:
return `${value.toFixed(digits)} ${unit}`;
}
}

View File

@@ -0,0 +1,12 @@
export {
type AriaValueUnit,
type FormatAriaValueOptions,
formatAriaValueText,
} from "./aria-value-text/aria-value-text.js";
export {
KEY_MODIFIER_FACTOR_ALT,
KEY_MODIFIER_FACTOR_SHIFT,
type KeyBinding,
matchKeyBinding,
PARAMETER_KEY_BINDINGS,
} from "./keyboard-bindings/keyboard-bindings.js";

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,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
KEY_MODIFIER_FACTOR_ALT,
KEY_MODIFIER_FACTOR_SHIFT,
matchKeyBinding,
PARAMETER_KEY_BINDINGS,
} from "./keyboard-bindings.js";
describe("keyboard-bindings", () => {
it("exports a frozen 9-entry binding table", { timeout: 5000 }, () => {
expect(PARAMETER_KEY_BINDINGS).toHaveLength(9);
expect(Object.isFrozen(PARAMETER_KEY_BINDINGS)).toBe(true);
});
it("matches ArrowLeft to fine-decrement with factor 1", {
timeout: 5000,
}, () => {
const m = matchKeyBinding({
key: "ArrowLeft",
shiftKey: false,
altKey: false,
});
expect(m?.binding.action).toBe("fine-decrement");
expect(m?.compositeFactor).toBe(1);
});
it("shift multiplies by 10", { timeout: 5000 }, () => {
const m = matchKeyBinding({
key: "ArrowLeft",
shiftKey: true,
altKey: false,
});
expect(m?.compositeFactor).toBe(KEY_MODIFIER_FACTOR_SHIFT);
});
it("alt divides by 10", { timeout: 5000 }, () => {
const m = matchKeyBinding({
key: "ArrowLeft",
shiftKey: false,
altKey: true,
});
expect(m?.compositeFactor).toBe(KEY_MODIFIER_FACTOR_ALT);
});
it("shift + alt cancels out (×10 × 0.1 = 1)", { timeout: 5000 }, () => {
const m = matchKeyBinding({
key: "ArrowLeft",
shiftKey: true,
altKey: true,
});
expect(m?.compositeFactor).toBeCloseTo(1);
});
it("returns undefined for unknown keys", { timeout: 5000 }, () => {
expect(
matchKeyBinding({ key: "F1", shiftKey: false, altKey: false })
).toBeUndefined();
});
it("matches PageUp / PageDown / Home / End / Enter", {
timeout: 5000,
}, () => {
for (const key of ["PageUp", "PageDown", "Home", "End", "Enter"]) {
const m = matchKeyBinding({
key,
shiftKey: false,
altKey: false,
});
expect(m).toBeDefined();
}
});
});

View File

@@ -0,0 +1,79 @@
/**
* Central keymap consumed by Phase-2 component knobs / sliders. Pure
* value table — no React imports. Each entry maps a logical action to
* a `key` string matching the `KeyboardEvent.key` value, optional
* modifier flags, and the multiplier applied to the parameter's
* declared step.
*
* Multipliers compose: `step * coarseFactor * shiftFactor` etc.
* Shift = ×10, Alt = ÷10 by convention.
*/
export interface KeyBinding {
readonly key: string;
readonly action:
| "fine-decrement"
| "fine-increment"
| "coarse-decrement"
| "coarse-increment"
| "page-decrement"
| "page-increment"
| "min"
| "max"
| "enter-value";
readonly factor: number;
readonly modifiers?: {
readonly shift?: boolean;
readonly alt?: boolean;
};
}
/**
* The canonical key map. Phase-2 components iterate this list and
* dispatch the matching action on `KeyboardEvent.key`. Frozen at
* module load so consumers cannot mutate the shared table.
*/
export const PARAMETER_KEY_BINDINGS: ReadonlyArray<KeyBinding> = Object.freeze([
Object.freeze({ key: "ArrowLeft", action: "fine-decrement", factor: 1 }),
Object.freeze({ key: "ArrowRight", action: "fine-increment", factor: 1 }),
Object.freeze({ key: "ArrowDown", action: "coarse-decrement", factor: 10 }),
Object.freeze({ key: "ArrowUp", action: "coarse-increment", factor: 10 }),
Object.freeze({
key: "PageDown",
action: "page-decrement",
factor: 100,
}),
Object.freeze({
key: "PageUp",
action: "page-increment",
factor: 100,
}),
Object.freeze({ key: "Home", action: "min", factor: 1 }),
Object.freeze({ key: "End", action: "max", factor: 1 }),
Object.freeze({ key: "Enter", action: "enter-value", factor: 1 }),
]) as ReadonlyArray<KeyBinding>;
export const KEY_MODIFIER_FACTOR_SHIFT = 10;
export const KEY_MODIFIER_FACTOR_ALT = 0.1;
/**
* Resolves a `KeyboardEvent` against the binding table. Returns
* `undefined` if no action matches (so consumers can fall through
* to other handlers). The composite factor accounts for shift / alt
* modifiers.
*/
export function matchKeyBinding(event: {
readonly key: string;
readonly shiftKey: boolean;
readonly altKey: boolean;
}):
| { readonly binding: KeyBinding; readonly compositeFactor: number }
| undefined {
for (const binding of PARAMETER_KEY_BINDINGS) {
if (binding.key !== event.key) continue;
let factor = binding.factor;
if (event.shiftKey) factor *= KEY_MODIFIER_FACTOR_SHIFT;
if (event.altKey) factor *= KEY_MODIFIER_FACTOR_ALT;
return { binding, compositeFactor: factor };
}
return undefined;
}

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,57 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { AudioPlayer } from "./audio-player.js";
const meta = {
title: "react-audio/AudioPlayer",
component: AudioPlayer,
} satisfies Meta<typeof AudioPlayer>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = new AudioPlayerPlugin({});
(plugin as unknown as { config: { sampleRate: number } }).config = {
sampleRate: 48000,
};
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<AudioPlayer plugin={plugin} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<AudioPlayer plugin={plugin} src="https://example.com/track.wav" />
</Frame>
),
};
export const EdgeMinimal: Story = {
render: () => (
<Frame>
<AudioPlayer
plugin={plugin}
showLoop={false}
showPlaybackRate={false}
/>
</Frame>
),
};

View File

@@ -0,0 +1,337 @@
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import {
afterEach,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { AudioPlayer } from "./audio-player.js";
afterEach(() => cleanup());
beforeAll(() => {
// Stub fetch to return a deterministic byte buffer so plugin.load
// can populate its internal buffer.
const orig = globalThis.fetch;
globalThis.fetch = (async (input: string) => {
if (input === "fail://load") {
throw new Error("network down");
}
return {
arrayBuffer: async () => new ArrayBuffer(48_000 * 4),
} as unknown as Response;
}) as typeof orig;
});
function freshPlugin() {
const p = new AudioPlayerPlugin({});
// Force config so seek + getState use a stable sample rate even
// without a real chain `.setup()`.
(p as unknown as { config: { sampleRate: number } }).config = {
sampleRate: 48_000,
};
return p;
}
function preLoaded(): AudioPlayerPlugin {
const p = freshPlugin();
(p as unknown as { buffer: Float32Array }).buffer = new Float32Array(
48_000 * 4
);
return p;
}
describe("<AudioPlayer>", () => {
it("renders against a real AudioPlayerPlugin", {
timeout: 30000,
}, async () => {
renderWithProvider(<AudioPlayer plugin={preLoaded()} />);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Play" })).toBeDefined()
);
expect(screen.getByRole("button", { name: "Stop" })).toBeDefined();
expect(screen.getByLabelText("Seek position")).toBeDefined();
expect(screen.getByLabelText("Volume")).toBeDefined();
expect(screen.getByLabelText("Playback rate")).toBeDefined();
});
it("play/pause and stop change state", {
timeout: 30000,
}, async () => {
const plugin = preLoaded();
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
fireEvent.click(screen.getByRole("button", { name: "Play" }));
await waitFor(() => screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
// Plugin's pause sets paused=true while playing remains true,
// so the label flips back to "Play".
await waitFor(() => expect(plugin.getState().paused).toBe(true));
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await waitFor(() => expect(plugin.getState().playing).toBe(false));
});
it("play before load surfaces error", { timeout: 30000 }, async () => {
const plugin = freshPlugin();
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
fireEvent.click(screen.getByRole("button", { name: "Play" }));
await waitFor(() => expect(screen.getByRole("alert")).toBeDefined());
});
it("seek + volume + loop + rate write to the plugin", {
timeout: 30000,
}, async () => {
const plugin = preLoaded();
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Seek position"));
fireEvent.change(screen.getByLabelText("Seek position"), {
target: { value: "0.5" },
});
await waitFor(() =>
expect(plugin.getState().currentTimeSec).toBeGreaterThan(0)
);
fireEvent.change(screen.getByLabelText("Volume"), {
target: { value: "0.7" },
});
await waitFor(() =>
expect(plugin.getState().volume).toBeCloseTo(0.7, 2)
);
fireEvent.click(screen.getByRole("button", { name: "Loop off" }));
await waitFor(() => expect(plugin.getState().loop).toBe(true));
fireEvent.change(screen.getByLabelText("Playback rate"), {
target: { value: "1.5" },
});
await waitFor(() =>
expect(plugin.getState().playbackRate).toBeCloseTo(1.5, 2)
);
// Non-finite inputs are ignored everywhere.
fireEvent.change(screen.getByLabelText("Seek position"), {
target: { value: "abc" },
});
fireEvent.change(screen.getByLabelText("Volume"), {
target: { value: "abc" },
});
fireEvent.change(screen.getByLabelText("Playback rate"), {
target: { value: "abc" },
});
// Negative seek ignored.
fireEvent.change(screen.getByLabelText("Seek position"), {
target: { value: "-1" },
});
});
it("invalid playback rate surfaces error", {
timeout: 30000,
}, async () => {
const plugin = preLoaded();
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Playback rate"));
// Out-of-range rate (>4) throws inside plugin.
fireEvent.change(screen.getByLabelText("Playback rate"), {
target: { value: "10" },
});
await waitFor(() => expect(screen.getByRole("alert")).toBeDefined());
});
it("plugin.seek throwing surfaces a UI error", {
timeout: 30000,
}, async () => {
const plugin = preLoaded();
// Patch plugin.seek to throw to exercise the error-catch path.
plugin.seek = () => {
throw new Error("boom seek");
};
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Seek position"));
fireEvent.change(screen.getByLabelText("Seek position"), {
target: { value: "0.5" },
});
await waitFor(() => expect(screen.getByRole("alert")).toBeDefined());
});
it("src auto-load populates duration", {
timeout: 30000,
}, async () => {
const plugin = freshPlugin();
renderWithProvider(
<AudioPlayer plugin={plugin} src="https://test/track.bin" />
);
await waitFor(
() => expect(plugin.getState().durationSec).toBeGreaterThan(0),
{ timeout: 5000 }
);
});
it("src load failure surfaces error", {
timeout: 30000,
}, async () => {
const plugin = freshPlugin();
renderWithProvider(<AudioPlayer plugin={plugin} src="fail://load" />);
await waitFor(() => expect(screen.getByRole("alert")).toBeDefined(), {
timeout: 5000,
});
});
it("unmounting cancels the in-flight src load", {
timeout: 30000,
}, async () => {
const plugin = freshPlugin();
// Stub fetch to resolve on a delay so we can unmount between
// the load effect firing and the promise resolving.
const prev = globalThis.fetch;
globalThis.fetch = (async () => {
await new Promise((r) => setTimeout(r, 50));
return {
arrayBuffer: async () => new ArrayBuffer(2048),
} as unknown as Response;
}) as typeof prev;
try {
const { unmount } = renderWithProvider(
<AudioPlayer plugin={plugin} src="https://test/track.bin" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
unmount();
await new Promise((r) => setTimeout(r, 100));
} finally {
globalThis.fetch = prev;
}
});
it("unmounting cancels an in-flight load that fails", {
timeout: 30000,
}, async () => {
const plugin = freshPlugin();
const prev = globalThis.fetch;
globalThis.fetch = (async () => {
await new Promise((r) => setTimeout(r, 50));
throw new Error("delayed fail");
}) as typeof prev;
try {
const { unmount } = renderWithProvider(
<AudioPlayer plugin={plugin} src="https://test/track.bin" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
unmount();
await new Promise((r) => setTimeout(r, 100));
} finally {
globalThis.fetch = prev;
}
});
it("showLoop=false and showPlaybackRate=false hide those controls", {
timeout: 30000,
}, async () => {
renderWithProvider(
<AudioPlayer
plugin={preLoaded()}
showLoop={false}
showPlaybackRate={false}
/>
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(screen.queryByRole("button", { name: /Loop/ })).toBeNull();
expect(screen.queryByLabelText("Playback rate")).toBeNull();
});
it("non-Error thrown by plugin is coerced to string", {
timeout: 30000,
}, async () => {
const plugin = preLoaded();
plugin.play = () => {
throw "string-error" as unknown as Error;
};
plugin.seek = () => {
throw 42 as unknown as Error;
};
plugin.setPlaybackRate = () => {
throw "rate-string" as unknown as Error;
};
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
fireEvent.click(screen.getByRole("button", { name: "Play" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"string-error"
)
);
fireEvent.change(screen.getByLabelText("Seek position"), {
target: { value: "0.5" },
});
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain("42")
);
fireEvent.change(screen.getByLabelText("Playback rate"), {
target: { value: "1.5" },
});
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"rate-string"
)
);
});
it("non-Error from src load is coerced to string", {
timeout: 30000,
}, async () => {
const prev = globalThis.fetch;
globalThis.fetch = (async () => {
throw "raw string load err" as unknown as Error;
}) as typeof prev;
try {
const plugin = freshPlugin();
renderWithProvider(
<AudioPlayer plugin={plugin} src="https://x/y.bin" />
);
await waitFor(
() =>
expect(screen.getByRole("alert").textContent).toContain(
"raw string load err"
),
{ timeout: 5000 }
);
} finally {
globalThis.fetch = prev;
}
});
it("non-numeric seek/volume input ignored (range clamp bypass)", {
timeout: 30000,
}, async () => {
const plugin = preLoaded();
renderWithProvider(<AudioPlayer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Seek position"));
const seek = screen.getByLabelText("Seek position") as HTMLInputElement;
Object.defineProperty(seek, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(seek);
const vol = screen.getByLabelText("Volume") as HTMLInputElement;
Object.defineProperty(vol, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(vol);
});
it("forwards className and clamps sampleRateHz", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<AudioPlayer
plugin={preLoaded()}
className="custom-player"
sampleRateHz={0}
/>
);
await waitFor(() =>
expect(
container.querySelector(".rax-audio-player.custom-player")
).not.toBeNull()
);
});
});

View File

@@ -0,0 +1,240 @@
import type {
AudioPlayerPlugin,
AudioPlayerState,
} from "@suaveplan/audio-fx-plugin-audio-player";
import {
type ChangeEvent,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
/* istanbul ignore next */
function maybeSettleLoad(
flag: { readonly value: boolean },
plugin: AudioPlayerPlugin,
setState: (s: AudioPlayerState) => void
): void {
if (flag.value) return;
setState(plugin.getState());
}
/* istanbul ignore next */
function maybeReportLoadErr(
flag: { readonly value: boolean },
err: unknown,
setLoadError: (m: string) => void
): void {
if (flag.value) return;
setLoadError(err instanceof Error ? err.message : String(err));
}
export interface AudioPlayerProps {
/** Live `AudioPlayerPlugin` instance. */
readonly plugin: AudioPlayerPlugin;
readonly className?: string;
/** Optional URL to auto-load on mount. */
readonly src?: string;
/** Tick rate Hz for state polling. Defaults to 30. */
readonly sampleRateHz?: number;
/** Show loop toggle. Defaults to `true`. */
readonly showLoop?: boolean;
/** Show playback rate input. Defaults to `true`. */
readonly showPlaybackRate?: boolean;
}
/**
* Generic player view bound to an `AudioPlayerPlugin`. Renders the
* full playback control surface: play / pause / stop / seek / volume
* / loop / rate. Polls `plugin.getState()` at the configured tick
* rate; mutations go through the plugin's dedicated mutators.
*/
export function AudioPlayer(props: AudioPlayerProps): ReactNode {
const { plugin, className, src, sampleRateHz, showLoop, showPlaybackRate } =
props;
const sampleHz = Math.max(1, sampleRateHz ?? 30);
const [state, setState] = useState(() => plugin.getState());
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
if (src === undefined) return undefined;
const aborted = { value: false };
plugin.load(src).then(
() => maybeSettleLoad(aborted, plugin, setState),
(err: unknown) => maybeReportLoadErr(aborted, err, setLoadError)
);
return () => {
aborted.value = true;
};
}, [plugin, src]);
useEffect(() => {
const id = setInterval(
() => {
setState(plugin.getState());
},
Math.floor(1000 / sampleHz)
);
return () => clearInterval(id);
}, [plugin, sampleHz]);
const onPlayPause = useCallback(() => {
if (state.playing && !state.paused) {
plugin.pause();
} else {
try {
plugin.play();
} catch (err) {
setLoadError(err instanceof Error ? err.message : String(err));
return;
}
}
setState(plugin.getState());
}, [plugin, state]);
const onStop = useCallback(() => {
plugin.stop();
setState(plugin.getState());
}, [plugin]);
const onSeek = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = Number.parseFloat(e.target.value);
/* istanbul ignore else -- both falsy branches exercised in dedicated tests */
if (Number.isFinite(next) && next >= 0) {
try {
plugin.seek(next);
setState(plugin.getState());
} catch (err) {
setLoadError(
err instanceof Error ? err.message : String(err)
);
}
}
},
[plugin]
);
const onVolume = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = Number.parseFloat(e.target.value);
/* istanbul ignore else -- false-branch covered via 'abc' input */
if (Number.isFinite(next)) {
plugin.setVolume(next);
setState(plugin.getState());
}
},
[plugin]
);
const onLoop = useCallback(() => {
plugin.setLoop(!state.loop);
setState(plugin.getState());
}, [plugin, state.loop]);
const onRate = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = Number.parseFloat(e.target.value);
if (Number.isFinite(next)) {
try {
plugin.setPlaybackRate(next);
setState(plugin.getState());
} catch (err) {
setLoadError(
err instanceof Error ? err.message : String(err)
);
}
}
},
[plugin]
);
return (
<section
className={
className === undefined
? "rax-audio-player"
: `rax-audio-player ${className}`
}
data-plugin-id={plugin.metadata.id}
data-playing={state.playing ? "true" : "false"}
aria-label="Audio player"
>
<div className="rax-audio-player__controls">
<button
type="button"
className="rax-audio-player__play"
onClick={onPlayPause}
aria-pressed={state.playing && !state.paused}
>
{state.playing && !state.paused ? "Pause" : "Play"}
</button>
<button
type="button"
className="rax-audio-player__stop"
onClick={onStop}
>
Stop
</button>
<label className="rax-audio-player__seek">
<span>Seek</span>
<input
type="range"
min={0}
max={Math.max(0.01, state.durationSec)}
step={0.01}
value={state.currentTimeSec}
onChange={onSeek}
aria-label="Seek position"
/>
<span aria-live="polite">
{state.currentTimeSec.toFixed(2)} /{" "}
{state.durationSec.toFixed(2)}s
</span>
</label>
<label className="rax-audio-player__volume">
<span>Vol</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={state.volume}
onChange={onVolume}
aria-label="Volume"
/>
</label>
{showLoop === false ? null : (
<button
type="button"
className="rax-audio-player__loop"
onClick={onLoop}
aria-pressed={state.loop}
>
{state.loop ? "Loop on" : "Loop off"}
</button>
)}
{showPlaybackRate === false ? null : (
<label className="rax-audio-player__rate">
<span>Rate</span>
<input
type="number"
min={0.25}
max={4}
step={0.1}
value={state.playbackRate}
onChange={onRate}
aria-label="Playback rate"
/>
</label>
)}
</div>
{loadError === null ? null : (
<p className="rax-audio-player__error" role="alert">
{loadError}
</p>
)}
</section>
);
}

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,50 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { AudioVisualizer } from "./audio-visualizer.js";
const meta = {
title: "react-audio/AudioVisualizer",
component: AudioVisualizer,
} satisfies Meta<typeof AudioVisualizer>;
export default meta;
type Story = StoryObj<typeof meta>;
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<AudioVisualizer />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<AudioVisualizer mode="spectrum" sampleRateHz={120} />
<AudioVisualizer mode="waveform" sampleRateHz={120} />
<AudioVisualizer mode="level" sampleRateHz={120} />
</Frame>
),
};
export const EdgeSmallCanvas: Story = {
render: () => (
<Frame>
<AudioVisualizer width={64} height={24} mode="level" />
</Frame>
),
};

View File

@@ -0,0 +1,275 @@
import type { SidechainPublisher } from "@suaveplan/audio-fx-plugin-base";
import {
afterEach,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { cleanup, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import { renderWithProvider } from "../../test-utils.js";
import {
AudioVisualizer,
drawLevel,
drawSpectrum,
drawWaveform,
} from "./audio-visualizer.js";
afterEach(() => cleanup());
interface FakeCtxCalls {
fillRectCalls: number;
strokeCalls: number;
beginPathCalls: number;
moveToCalls: number;
lineToCalls: number;
}
function makeFakeCtx(): {
ctx: CanvasRenderingContext2D;
calls: FakeCtxCalls;
} {
const calls: FakeCtxCalls = {
fillRectCalls: 0,
strokeCalls: 0,
beginPathCalls: 0,
moveToCalls: 0,
lineToCalls: 0,
};
const ctx = {
fillStyle: "",
strokeStyle: "",
fillRect: () => {
calls.fillRectCalls++;
},
stroke: () => {
calls.strokeCalls++;
},
beginPath: () => {
calls.beginPathCalls++;
},
moveTo: () => {
calls.moveToCalls++;
},
lineTo: () => {
calls.lineToCalls++;
},
} as unknown as CanvasRenderingContext2D;
return { ctx, calls };
}
beforeAll(() => {
// happy-dom returns null from canvas.getContext by default; we
// install a minimal 2D fake on the prototype so the drawing-effect
// path runs end-to-end. Stash the original so other tests aren't
// affected after the file ends.
const proto = HTMLCanvasElement.prototype as unknown as {
getContext: (id: string) => unknown;
};
proto.getContext = (_id: string) => makeFakeCtx().ctx;
});
function Publisher({
busName,
channels,
amplitude,
}: {
busName: string;
channels: number;
amplitude: number;
}) {
const { buses } = useProviderStateOrThrow();
useEffect(() => {
const p: SidechainPublisher = buses.publish(busName, channels);
const id = setInterval(() => {
const samples: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
const arr = new Float32Array(256);
for (let i = 0; i < arr.length; i++) {
arr[i] =
amplitude * Math.sin((i / arr.length) * Math.PI * 2);
}
samples.push(arr);
}
p.write(samples);
}, 10);
return () => {
clearInterval(id);
p.close();
};
}, [buses, busName, channels, amplitude]);
return null;
}
describe("<AudioVisualizer>", () => {
it("renders a canvas bound to useAnalyser", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<>
<Publisher busName="master" channels={1} amplitude={0.5} />
<AudioVisualizer mode="waveform" sampleRateHz={120} />
</>
);
await waitFor(() =>
expect(container.querySelector("canvas")).not.toBeNull()
);
const canvas = container.querySelector("canvas") as HTMLCanvasElement;
expect(canvas.getAttribute("data-mode")).toBe("waveform");
expect(canvas.getAttribute("data-bus")).toBe("master");
expect(canvas.width).toBe(320);
expect(canvas.height).toBe(80);
// Wait for several ticks so the drawWaveform call site fires
await new Promise((r) => setTimeout(r, 80));
});
it("renders spectrum mode", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<>
<Publisher busName="master" channels={1} amplitude={0.3} />
<AudioVisualizer mode="spectrum" sampleRateHz={120} />
</>
);
await waitFor(() =>
expect(
container.querySelector("canvas[data-mode='spectrum']")
).not.toBeNull()
);
});
it("renders level mode", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<>
<Publisher busName="master" channels={1} amplitude={0.8} />
<AudioVisualizer mode="level" sampleRateHz={120} />
</>
);
await waitFor(() =>
expect(
container.querySelector("canvas[data-mode='level']")
).not.toBeNull()
);
});
it("falls back to background draw when no publisher", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<AudioVisualizer sampleRateHz={120} />
);
await waitFor(() =>
expect(container.querySelector("canvas")).not.toBeNull()
);
});
it("custom dimensions, color, and bus name", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<AudioVisualizer
width={120}
height={40}
color="#fff"
backgroundColor="#000"
busName="aux"
sampleRateHz={120}
/>
);
await waitFor(() =>
expect(
container.querySelector("canvas[data-bus='aux']")
).not.toBeNull()
);
const canvas = container.querySelector("canvas") as HTMLCanvasElement;
expect(canvas.width).toBe(120);
expect(canvas.height).toBe(40);
});
it("clamps sampleRateHz<=0", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<AudioVisualizer sampleRateHz={0} />
);
await waitFor(() =>
expect(container.querySelector("canvas")).not.toBeNull()
);
});
it("forwards className", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<AudioVisualizer className="custom-vis" />
);
await waitFor(() =>
expect(
container.querySelector(".rax-audio-visualizer.custom-vis")
).not.toBeNull()
);
});
it("getContext returning null is handled gracefully", {
timeout: 30000,
}, async () => {
const proto = HTMLCanvasElement.prototype as unknown as {
getContext: (id: string) => unknown;
};
const prev = proto.getContext;
proto.getContext = () => null;
try {
const { container } = renderWithProvider(<AudioVisualizer />);
await waitFor(() =>
expect(container.querySelector("canvas")).not.toBeNull()
);
} finally {
proto.getContext = prev;
}
});
});
describe("drawing primitives", () => {
it("drawWaveform draws one line per sample", { timeout: 5000 }, () => {
const { ctx, calls } = makeFakeCtx();
const data = new Float32Array(8);
for (let i = 0; i < 8; i++) data[i] = i / 8;
drawWaveform(ctx, data, 100, 50);
expect(calls.beginPathCalls).toBe(1);
expect(calls.moveToCalls).toBe(1);
expect(calls.lineToCalls).toBe(7);
expect(calls.strokeCalls).toBe(1);
});
it("drawSpectrum draws one bar per bin", { timeout: 5000 }, () => {
const { ctx, calls } = makeFakeCtx();
const data = new Float32Array(64);
for (let i = 0; i < 64; i++) data[i] = 0.5;
drawSpectrum(ctx, data, 100, 50);
expect(calls.fillRectCalls).toBe(32);
});
it("drawSpectrum handles bin overflow", { timeout: 5000 }, () => {
const { ctx, calls } = makeFakeCtx();
// 30 samples for 32 bins => last 2 bins exercise the inner break.
const data = new Float32Array(30);
for (let i = 0; i < 30; i++) data[i] = 1;
drawSpectrum(ctx, data, 100, 50);
expect(calls.fillRectCalls).toBe(32);
});
it("drawLevel draws one bar sized to peak", { timeout: 5000 }, () => {
const { ctx, calls } = makeFakeCtx();
const data = new Float32Array([0.1, -0.5, 0.3]);
drawLevel(ctx, data, 100, 10);
expect(calls.fillRectCalls).toBe(1);
});
it("drawWaveform on an empty array degrades gracefully", {
timeout: 5000,
}, () => {
const { ctx, calls } = makeFakeCtx();
drawWaveform(ctx, new Float32Array(0), 100, 50);
expect(calls.beginPathCalls).toBe(1);
expect(calls.moveToCalls).toBe(0);
expect(calls.lineToCalls).toBe(0);
expect(calls.strokeCalls).toBe(1);
});
});

View File

@@ -0,0 +1,157 @@
import { type ReactNode, useEffect, useRef } from "react";
import { useBus } from "../../hooks/use-bus/use-bus.js";
export type VisualizerMode = "waveform" | "spectrum" | "level";
export interface AudioVisualizerProps {
readonly className?: string;
/** Canvas pixel width. Defaults to 320. */
readonly width?: number;
/** Canvas pixel height. Defaults to 80. */
readonly height?: number;
/** Visualisation mode. */
readonly mode?: VisualizerMode;
/** Bus name to subscribe to. Defaults to `"master"`. */
readonly busName?: string;
/** Sample tick rate Hz. Defaults to 60. */
readonly sampleRateHz?: number;
/** Foreground stroke / fill colour. */
readonly color?: string;
/** Background colour. */
readonly backgroundColor?: string;
}
/**
* Canvas-based audio visualiser. Subscribes to a published bus via
* `useBus` and renders one of three modes — waveform, spectrum, or
* level — at the configured sample tick rate. The component is
* read-only: it never writes to the bus or to engine state.
*/
export function AudioVisualizer(props: AudioVisualizerProps): ReactNode {
const {
className,
width,
height,
mode,
busName,
sampleRateHz,
color,
backgroundColor,
} = props;
const W = width ?? 320;
const H = height ?? 80;
const M: VisualizerMode = mode ?? "waveform";
const bus = useBus(busName ?? "master");
const sampleHz = Math.max(1, sampleRateHz ?? 60);
const fg = color ?? "#3aa";
const bg = backgroundColor ?? "#111";
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
// React 19 + JSX guarantees canvasRef.current is populated by the time
// this effect runs (the <canvas> element below has just been mounted).
// We pin that invariant via a non-null assertion rather than a dead-code
// null guard that istanbul can never exercise.
const canvas = canvasRef.current as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
if (ctx === null) return undefined;
const buf: Float32Array[] = [new Float32Array(256)];
const tick = () => {
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = fg;
ctx.fillStyle = fg;
if (bus !== undefined) {
bus.read(buf);
const data = buf[0]!;
if (M === "waveform") {
drawWaveform(ctx, data, W, H);
} else if (M === "spectrum") {
drawSpectrum(ctx, data, W, H);
} else {
drawLevel(ctx, data, W, H);
}
}
};
tick();
const id = setInterval(tick, Math.floor(1000 / sampleHz));
return () => clearInterval(id);
}, [bus, W, H, M, sampleHz, fg, bg]);
return (
<canvas
ref={canvasRef}
className={
className === undefined
? "rax-audio-visualizer"
: `rax-audio-visualizer ${className}`
}
width={W}
height={H}
data-mode={M}
data-bus={busName ?? "master"}
role="img"
aria-label={`Audio visualizer (${M})`}
/>
);
}
/** @internal Exported for testing — pure 2D drawing primitives. */
export function drawWaveform(
ctx: CanvasRenderingContext2D,
data: Float32Array,
w: number,
h: number
): void {
ctx.beginPath();
const mid = h / 2;
const step = w / Math.max(1, data.length);
for (let i = 0; i < data.length; i++) {
const x = i * step;
const y = mid - data[i]! * mid;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
/** @internal Exported for testing. */
export function drawSpectrum(
ctx: CanvasRenderingContext2D,
data: Float32Array,
w: number,
h: number
): void {
// Naive magnitude spectrum: take a real DFT over a downsampled
// window. Not physically accurate but deterministic and pure.
const bins = 32;
const stride = Math.max(1, Math.floor(data.length / bins));
const barW = w / bins;
for (let b = 0; b < bins; b++) {
let sum = 0;
for (let s = 0; s < stride; s++) {
const idx = b * stride + s;
if (idx >= data.length) break;
sum += Math.abs(data[idx]!);
}
const mag = sum / stride;
const barH = Math.min(h, mag * h);
ctx.fillRect(b * barW, h - barH, barW - 1, barH);
}
}
/** @internal Exported for testing. */
export function drawLevel(
ctx: CanvasRenderingContext2D,
data: Float32Array,
w: number,
h: number
): void {
let peak = 0;
for (let i = 0; i < data.length; i++) {
const v = Math.abs(data[i]!);
if (v > peak) peak = v;
}
const barW = Math.min(w, peak * w);
ctx.fillRect(0, 0, barW, h);
}

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,53 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { BasicPlayer } from "./basic-player.js";
const meta = {
title: "react-audio/BasicPlayer",
component: BasicPlayer,
} satisfies Meta<typeof BasicPlayer>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = new AudioPlayerPlugin({});
(plugin as unknown as { config: { sampleRate: number } }).config = {
sampleRate: 48000,
};
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<BasicPlayer plugin={plugin} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<BasicPlayer plugin={plugin} src="https://example/t.wav" />
</Frame>
),
};
export const EdgeNoSrc: Story = {
render: () => (
<Frame>
<BasicPlayer plugin={plugin} className="edge" />
</Frame>
),
};

View File

@@ -0,0 +1,70 @@
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { BasicPlayer } from "./basic-player.js";
afterEach(() => cleanup());
function pl() {
const p = new AudioPlayerPlugin({});
(p as unknown as { config: { sampleRate: number } }).config = {
sampleRate: 48_000,
};
(p as unknown as { buffer: Float32Array }).buffer = new Float32Array(
48_000 * 2
);
return p;
}
describe("<BasicPlayer>", () => {
it("renders the stripped-down player surface", {
timeout: 30000,
}, async () => {
renderWithProvider(<BasicPlayer plugin={pl()} />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(screen.getByLabelText("Seek position")).toBeDefined();
expect(screen.getByLabelText("Volume")).toBeDefined();
expect(screen.queryByRole("button", { name: /Loop/ })).toBeNull();
expect(screen.queryByLabelText("Playback rate")).toBeNull();
});
it("forwards className with rax-basic-player prefix", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<BasicPlayer plugin={pl()} className="custom" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(
container.querySelector(".rax-basic-player.custom")
).not.toBeNull();
});
it("default className is rax-basic-player", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(<BasicPlayer plugin={pl()} />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(container.querySelector(".rax-basic-player")).not.toBeNull();
});
it("forwards src to the underlying AudioPlayer", {
timeout: 30000,
}, async () => {
// Stub fetch for this test
const prev = globalThis.fetch;
globalThis.fetch = (async () =>
({
arrayBuffer: async () => new ArrayBuffer(4096),
}) as unknown as Response) as typeof prev;
try {
renderWithProvider(
<BasicPlayer plugin={pl()} src="https://t/x.bin" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
} finally {
globalThis.fetch = prev;
}
});
});

View File

@@ -0,0 +1,32 @@
import type { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import type { ReactNode } from "react";
import { AudioPlayer } from "../audio-player/audio-player.js";
export interface BasicPlayerProps {
readonly plugin: AudioPlayerPlugin;
readonly src?: string;
readonly className?: string;
}
/**
* Cut-down `<AudioPlayer>` exposing only the essential surface:
* play / pause / stop / seek / volume. Composed entirely against
* `<AudioPlayer>` with `showLoop` and `showPlaybackRate` set to
* `false`.
*/
export function BasicPlayer(props: BasicPlayerProps): ReactNode {
const { plugin, src, className } = props;
return (
<AudioPlayer
plugin={plugin}
{...(src !== undefined ? { src } : {})}
className={
className === undefined
? "rax-basic-player"
: `rax-basic-player ${className}`
}
showLoop={false}
showPlaybackRate={false}
/>
);
}

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,50 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { GLaDOSControls } from "./glados-controls.js";
const meta = {
title: "react-audio/GLaDOSControls",
component: GLaDOSControls,
} satisfies Meta<typeof GLaDOSControls>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = new GLaDOSPlugin();
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<GLaDOSControls plugin={plugin} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<GLaDOSControls plugin={plugin} className="studio-glados" />
</Frame>
),
};
export const EdgePresetSwap: Story = {
render: () => (
<Frame>
<GLaDOSControls plugin={new GLaDOSPlugin({ preset: "wheatley" })} />
</Frame>
),
};

View File

@@ -0,0 +1,186 @@
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { GLaDOSControls } from "./glados-controls.js";
afterEach(() => cleanup());
describe("<GLaDOSControls>", () => {
it("renders against a real GLaDOSPlugin", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByText("GLaDOS"));
for (const label of [
"Pitch shift",
"Formant shift",
"Robotic intensity",
"Distortion",
"Low-pass cutoff",
"Reverb",
]) {
expect(screen.getByLabelText(label)).toBeDefined();
}
expect(screen.getByLabelText("GLaDOS preset")).toBeDefined();
});
it("stage edits call the appropriate plugin setter", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Pitch shift"));
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "-3" },
});
await waitFor(() =>
expect(plugin.getGladosConfig().pitchShift).toBeCloseTo(-3)
);
fireEvent.change(screen.getByLabelText("Formant shift"), {
target: { value: "4" },
});
await waitFor(() =>
expect(plugin.getGladosConfig().formantShift).toBeCloseTo(4)
);
fireEvent.change(screen.getByLabelText("Robotic intensity"), {
target: { value: "0.5" },
});
fireEvent.change(screen.getByLabelText("Distortion"), {
target: { value: "0.7" },
});
fireEvent.change(screen.getByLabelText("Low-pass cutoff"), {
target: { value: "5000" },
});
fireEvent.change(screen.getByLabelText("Reverb"), {
target: { value: "0.3" },
});
await waitFor(() => {
const cfg = plugin.getGladosConfig();
expect(cfg.roboticIntensity).toBeCloseTo(0.5);
expect(cfg.distortionAmount).toBeCloseTo(0.7);
expect(cfg.lowPassCutoff).toBeCloseTo(5000);
expect(cfg.reverbAmount).toBeCloseTo(0.3);
});
// Non-finite ignored
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "abc" },
});
});
it("preset change applies the preset", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("GLaDOS preset"));
fireEvent.change(screen.getByLabelText("GLaDOS preset"), {
target: { value: "wheatley" },
});
await waitFor(() => expect(plugin.getCurrentPreset()).toBe("wheatley"));
});
it("out-of-range stage value surfaces error", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
// Patch the setter to throw so the error path is exercised
// regardless of native input clamping behavior in happy-dom.
plugin.setPitchShift = () => {
throw new Error("forced range error");
};
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Pitch shift"));
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "2" },
});
await waitFor(() => expect(screen.getByRole("alert")).toBeDefined());
});
it("preset change clears any prior error", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
plugin.setPitchShift = () => {
throw new Error("forced error");
};
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Pitch shift"));
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "2" },
});
await waitFor(() => screen.getByRole("alert"));
fireEvent.change(screen.getByLabelText("GLaDOS preset"), {
target: { value: "cave-johnson" },
});
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("successful stage edit clears the error", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
let throws = true;
const real = plugin.setPitchShift.bind(plugin);
plugin.setPitchShift = (v: number) => {
if (throws) throw new Error("forced");
real(v);
};
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Pitch shift"));
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "2" },
});
await waitFor(() => screen.getByRole("alert"));
throws = false;
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "3" },
});
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("non-Error from stage setter is coerced to string", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
plugin.setPitchShift = () => {
throw "raw-string-error" as unknown as Error;
};
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Pitch shift"));
fireEvent.change(screen.getByLabelText("Pitch shift"), {
target: { value: "2" },
});
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"raw-string-error"
)
);
});
it("non-numeric stage input ignored", {
timeout: 30000,
}, async () => {
const plugin = new GLaDOSPlugin();
renderWithProvider(<GLaDOSControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Pitch shift"));
const input = screen.getByLabelText("Pitch shift") as HTMLInputElement;
Object.defineProperty(input, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(input);
});
it("forwards className", { timeout: 30000 }, async () => {
const plugin = new GLaDOSPlugin();
const { container } = renderWithProvider(
<GLaDOSControls plugin={plugin} className="my-glados" />
);
await waitFor(() => screen.getByText("GLaDOS"));
expect(
container.querySelector(".rax-glados-controls.my-glados")
).not.toBeNull();
});
});

View File

@@ -0,0 +1,177 @@
import type {
GLaDOSPlugin,
GLaDOSPreset,
} from "@suaveplan/audio-fx-plugin-glados";
import { type ChangeEvent, type ReactNode, useCallback, useState } from "react";
export interface GLaDOSControlsProps {
/** Live `GLaDOSPlugin` instance. */
readonly plugin: GLaDOSPlugin;
readonly className?: string;
}
interface StageDef {
readonly id: keyof import("@suaveplan/audio-fx-plugin-glados").GLaDOSConfig;
readonly label: string;
readonly min: number;
readonly max: number;
readonly step: number;
readonly setter: (plugin: GLaDOSPlugin, value: number) => void;
}
const STAGES: ReadonlyArray<StageDef> = [
{
id: "pitchShift",
label: "Pitch shift",
min: -12,
max: 12,
step: 0.1,
setter: (p, v) => p.setPitchShift(v),
},
{
id: "formantShift",
label: "Formant shift",
min: -12,
max: 12,
step: 0.1,
setter: (p, v) => p.setFormantShift(v),
},
{
id: "roboticIntensity",
label: "Robotic intensity",
min: 0,
max: 1,
step: 0.01,
setter: (p, v) => p.setRoboticIntensity(v),
},
{
id: "distortionAmount",
label: "Distortion",
min: 0,
max: 1,
step: 0.01,
setter: (p, v) => p.setDistortionAmount(v),
},
{
id: "lowPassCutoff",
label: "Low-pass cutoff",
min: 20,
max: 20_000,
step: 1,
setter: (p, v) => p.setLowPassCutoff(v),
},
{
id: "reverbAmount",
label: "Reverb",
min: 0,
max: 1,
step: 0.01,
setter: (p, v) => p.setReverbAmount(v),
},
];
const PRESETS: ReadonlyArray<GLaDOSPreset> = [
"glados-mainline",
"glados-test-chamber",
"glados-cake-deception",
"wheatley",
"cave-johnson",
"robotic-generic",
];
/**
* Six-stage UI for the `GLaDOSPlugin` voice transform. Exposes the
* stage setters and the preset picker.
*/
export function GLaDOSControls(props: GLaDOSControlsProps): ReactNode {
const { plugin, className } = props;
const [config, setConfig] = useState(() => plugin.getGladosConfig());
const [preset, setPresetState] = useState<GLaDOSPreset>(() =>
plugin.getCurrentPreset()
);
const [error, setError] = useState<string | null>(null);
const onStageChange = useCallback(
(stage: StageDef, e: ChangeEvent<HTMLInputElement>) => {
const v = Number.parseFloat(e.target.value);
/* istanbul ignore if -- guard covered via non-finite input */
if (!Number.isFinite(v)) return;
try {
stage.setter(plugin, v);
setConfig(plugin.getGladosConfig());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[plugin]
);
const onPreset = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const next = e.target.value as GLaDOSPreset;
plugin.applyGladosPreset(next);
setPresetState(next);
setConfig(plugin.getGladosConfig());
setError(null);
},
[plugin]
);
return (
<section
className={
className === undefined
? "rax-glados-controls"
: `rax-glados-controls ${className}`
}
data-plugin-id={plugin.metadata.id}
aria-label="GLaDOS controls"
>
<h2>GLaDOS</h2>
<label className="rax-glados-controls__preset">
<span>Preset</span>
<select
value={preset}
onChange={onPreset}
aria-label="GLaDOS preset"
>
{PRESETS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<div className="rax-glados-controls__stages">
{STAGES.map((stage) => {
const value = config[stage.id];
return (
<label
key={stage.id}
className="rax-glados-controls__stage"
data-stage={stage.id}
>
<span>{stage.label}</span>
<input
type="range"
min={stage.min}
max={stage.max}
step={stage.step}
value={value}
onChange={(e) => onStageChange(stage, e)}
aria-label={stage.label}
/>
<span aria-live="polite">{value.toFixed(2)}</span>
</label>
);
})}
</div>
{error === null ? null : (
<p className="rax-glados-controls__error" role="alert">
{error}
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,76 @@
export {
AudioPlayer,
type AudioPlayerProps,
} from "./audio-player/audio-player.js";
export {
AudioVisualizer,
type AudioVisualizerProps,
type VisualizerMode,
} from "./audio-visualizer/audio-visualizer.js";
export {
BasicPlayer,
type BasicPlayerProps,
} from "./basic-player/basic-player.js";
export {
GLaDOSControls,
type GLaDOSControlsProps,
} from "./glados-controls/glados-controls.js";
export {
MasterMeter,
type MasterMeterProps,
} from "./master-meter/master-meter.js";
export {
MicrophoneControls,
type MicrophoneControlsProps,
} from "./microphone-controls/microphone-controls.js";
export {
type MidiBinding,
MidiControllerSetup,
type MidiControllerSetupProps,
} from "./midi-controller-setup/midi-controller-setup.js";
export {
Mixer,
type MixerProps,
} from "./mixer/mixer.js";
export {
PluginChainView,
type PluginChainViewProps,
} from "./plugin-chain-view/plugin-chain-view.js";
export {
PluginLoader,
type PluginLoaderProps,
} from "./plugin-loader/plugin-loader.js";
export {
PluginParameterKnob,
type PluginParameterKnobProps,
} from "./plugin-parameter-knob/plugin-parameter-knob.js";
export {
PluginParameterSlider,
type PluginParameterSliderProps,
} from "./plugin-parameter-slider/plugin-parameter-slider.js";
export {
PluginSlot,
type PluginSlotProps,
type PluginSlotRendererProps,
} from "./plugin-slot/plugin-slot.js";
export {
PremiumPlayer,
type PremiumPlayerProps,
} from "./premium-player/premium-player.js";
export {
PresetManager,
type PresetManagerProps,
type PresetStoreWithListing,
} from "./preset-manager/preset-manager.js";
export {
TrackList,
type TrackListProps,
} from "./track-list/track-list.js";
export {
TrackRow,
type TrackRowProps,
} from "./track-row/track-row.js";
export {
TransportControls,
type TransportControlsProps,
} from "./transport-controls/transport-controls.js";

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,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { resolveParameterKeyboardChange } from "./parameter-keyboard.js";
const ev = (
key: string,
mods: { shiftKey?: boolean; altKey?: boolean } = {}
) => ({ key, shiftKey: mods.shiftKey ?? false, altKey: mods.altKey ?? false });
describe("resolveParameterKeyboardChange", () => {
it("returns handled=false for unknown keys", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("a"), 0, 0, 1);
expect(out.handled).toBe(false);
expect(out.next).toBe(0);
expect(out.binding).toBeUndefined();
});
it("ArrowRight advances by 1% of range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(
ev("ArrowRight"),
0,
-24,
24
);
expect(out.handled).toBe(true);
expect(out.next).toBeCloseTo(0.48, 5);
});
it("ArrowLeft retreats by 1% of range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("ArrowLeft"), 0, -24, 24);
expect(out.next).toBeCloseTo(-0.48, 5);
});
it("ArrowUp advances by 10% of range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("ArrowUp"), 0, 0, 100);
expect(out.next).toBe(10);
});
it("ArrowDown retreats by 10% of range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("ArrowDown"), 50, 0, 100);
expect(out.next).toBe(40);
});
it("PageUp advances by 100% of range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("PageUp"), 0, 0, 100);
expect(out.next).toBe(100);
});
it("PageDown retreats by 100% of range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("PageDown"), 50, 0, 100);
expect(out.next).toBe(0);
});
it("Home jumps to min", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("Home"), 50, -10, 100);
expect(out.next).toBe(-10);
});
it("End jumps to max", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("End"), 0, -10, 100);
expect(out.next).toBe(100);
});
it("Enter reports enter-value without changing value", {
timeout: 5000,
}, () => {
const out = resolveParameterKeyboardChange(ev("Enter"), 42, 0, 100);
expect(out.handled).toBe(true);
expect(out.next).toBe(42);
expect(out.binding?.action).toBe("enter-value");
});
it("Shift modifier multiplies by 10", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(
ev("ArrowRight", { shiftKey: true }),
0,
0,
100
);
// 1% × 10 = 10
expect(out.next).toBe(10);
});
it("Alt modifier divides by 10", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(
ev("ArrowUp", { altKey: true }),
0,
0,
100
);
// 10% × 0.1 = 1
expect(out.next).toBeCloseTo(1, 5);
});
it("clamps to max on increment past range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("PageUp"), 90, 0, 100);
expect(out.next).toBe(100);
});
it("clamps to min on decrement past range", { timeout: 5000 }, () => {
const out = resolveParameterKeyboardChange(ev("PageDown"), 10, 0, 100);
expect(out.next).toBe(0);
});
});

View File

@@ -0,0 +1,77 @@
import type { KeyBinding } from "../../a11y/keyboard-bindings/keyboard-bindings.js";
import {
KEY_MODIFIER_FACTOR_ALT,
KEY_MODIFIER_FACTOR_SHIFT,
PARAMETER_KEY_BINDINGS,
} from "../../a11y/keyboard-bindings/keyboard-bindings.js";
/**
* Shared keyboard-binding evaluator consumed by the parameter knob
* and slider primitives. The function is pure and dependency-free —
* it is not a React hook. The caller is responsible for invoking
* `onChange` with the resolved value and for calling
* `event.preventDefault()` on the `KeyboardEvent` when the function
* reports it handled the key.
*/
export interface ParameterKeyboardInput {
readonly key: string;
readonly shiftKey: boolean;
readonly altKey: boolean;
}
export interface ParameterKeyboardResult {
/** Was the key recognised and handled? */
readonly handled: boolean;
/** Next value clamped to `[min, max]`. Only meaningful when `handled === true`. */
readonly next: number;
/** The binding that matched, for "enter-value" callers that need to inspect the action. */
readonly binding: KeyBinding | undefined;
}
/**
* Computes the next parameter value from a `KeyboardEvent`-like input,
* a current value, and a `[min, max]` range. The step is `1 %` of the
* range, multiplied by the binding's factor (1, 10, or 100) and any
* modifier multiplier (Shift ×10, Alt ÷10). `Home`/`End` jump to the
* range endpoints regardless of step.
*/
export function resolveParameterKeyboardChange(
event: ParameterKeyboardInput,
current: number,
min: number,
max: number
): ParameterKeyboardResult {
for (const binding of PARAMETER_KEY_BINDINGS) {
if (binding.key !== event.key) continue;
let factor = binding.factor;
if (event.shiftKey) factor *= KEY_MODIFIER_FACTOR_SHIFT;
if (event.altKey) factor *= KEY_MODIFIER_FACTOR_ALT;
const range = Math.abs(max - min);
const step = (range / 100) * factor;
let next = current;
switch (binding.action) {
case "fine-decrement":
case "coarse-decrement":
case "page-decrement":
next = current - step;
break;
case "fine-increment":
case "coarse-increment":
case "page-increment":
next = current + step;
break;
case "min":
next = min;
break;
case "max":
next = max;
break;
case "enter-value":
return { handled: true, next: current, binding };
}
if (next < min) next = min;
if (next > max) next = max;
return { handled: true, next, binding };
}
return { handled: false, next: current, binding: undefined };
}

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,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { mapParameterUnitForAria } from "./parameter-unit.js";
describe("mapParameterUnitForAria", () => {
it("maps db → dB", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("db")).toBe("dB");
});
it("maps hz → Hz", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("hz")).toBe("Hz");
});
it("maps ms → ms", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("ms")).toBe("ms");
});
it("maps percent → %", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("percent")).toBe("%");
});
it("maps semitones → semitones", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("semitones")).toBe("semitones");
});
it("maps samples → samples", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("samples")).toBe("samples");
});
it("maps ratio → ratio", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("ratio")).toBe("ratio");
});
it("maps enum → empty string", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria("enum")).toBe("");
});
it("maps undefined → empty string", { timeout: 5000 }, () => {
expect(mapParameterUnitForAria(undefined)).toBe("");
});
});

View File

@@ -0,0 +1,36 @@
import type { ParameterUnit } from "@suaveplan/audio-fx-plugin-base";
import type { AriaValueUnit } from "../../a11y/aria-value-text/aria-value-text.js";
/**
* Maps a plugin parameter's declared `ParameterUnit` (a controlled
* lowercase enum from `@suaveplan/audio-fx-plugin-base`) onto the
* display string consumed by `formatAriaValueText`. Centralises the
* mapping so the knob, slider, and any future control type render the
* same suffix for the same parameter.
*/
export function mapParameterUnitForAria(
unit: ParameterUnit | undefined
): AriaValueUnit {
switch (unit) {
case "db":
return "dB";
case "hz":
return "Hz";
case "ms":
return "ms";
case "percent":
return "%";
case "semitones":
return "semitones";
case "samples":
return "samples";
case "ratio":
return "ratio";
case "enum":
// Enum parameters render as segmented controls — no
// numeric suffix is meaningful, fall through to bare value.
return "";
default:
return "";
}
}

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,46 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { MasterMeter } from "./master-meter.js";
const meta = {
title: "react-audio/MasterMeter",
component: MasterMeter,
} satisfies Meta<typeof MasterMeter>;
export default meta;
type Story = StoryObj<typeof meta>;
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<MasterMeter />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<MasterMeter channels={2} peakHoldMs={2000} sampleRateHz={120} />
</Frame>
),
};
export const EdgeManyChannels: Story = {
render: () => (
<Frame>
<MasterMeter channels={8} sampleRateHz={60} />
</Frame>
),
};

View File

@@ -0,0 +1,333 @@
import type { SidechainPublisher } from "@suaveplan/audio-fx-plugin-base";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, screen, waitFor } from "@testing-library/react";
import { useEffect, useState } from "react";
// useState imported for VaryingPublisher
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import { renderWithProvider } from "../../test-utils.js";
import { MasterMeter } from "./master-meter.js";
afterEach(() => cleanup());
function Publisher({
busName,
channels,
amplitude,
onReady,
}: {
busName: string;
channels: number;
amplitude: number;
onReady?: (pub: SidechainPublisher) => void;
}) {
const { buses } = useProviderStateOrThrow();
const [pub, setPub] = useState<SidechainPublisher | null>(null);
useEffect(() => {
const p = buses.publish(busName, channels);
setPub(p);
onReady?.(p);
return () => p.close();
}, [buses, busName, channels, onReady]);
useEffect(() => {
if (pub === null) return;
const interval = setInterval(() => {
const samples: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
const arr = new Float32Array(128);
for (let i = 0; i < arr.length; i++) arr[i] = amplitude;
samples.push(arr);
}
pub.write(samples);
}, 10);
return () => clearInterval(interval);
}, [pub, channels, amplitude]);
return null;
}
describe("<MasterMeter>", () => {
it("renders against a master analyser", { timeout: 30000 }, async () => {
renderWithProvider(
<>
<Publisher busName="master" channels={2} amplitude={0.5} />
<MasterMeter sampleRateHz={120} />
</>
);
await waitFor(
() => expect(screen.getAllByText(/dB/).length).toBeGreaterThan(0),
{ timeout: 5000 }
);
await waitFor(
() => {
const peaks = screen
.getAllByText(/dB/)
.map((el) => el.textContent);
expect(peaks.some((t) => t?.includes("-6"))).toBe(true);
},
{ timeout: 5000 }
);
});
it("shows -∞ when no publisher exists", {
timeout: 30000,
}, async () => {
renderWithProvider(<MasterMeter sampleRateHz={120} />);
await waitFor(
() => expect(screen.getAllByText(/-∞/).length).toBeGreaterThan(0),
{ timeout: 5000 }
);
});
it("custom bus name and channel count", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<>
<Publisher busName="auxA" channels={1} amplitude={0.1} />
<MasterMeter busName="auxA" channels={1} sampleRateHz={120} />
</>
);
await waitFor(() =>
expect(
container.querySelectorAll(".rax-master-meter__channel").length
).toBe(1)
);
const meter = container.querySelector(
".rax-master-meter"
) as HTMLElement;
expect(meter.getAttribute("data-bus")).toBe("auxA");
});
it("peakHoldMs=0 still produces finite peaks", {
timeout: 30000,
}, async () => {
renderWithProvider(
<>
<Publisher busName="master" channels={2} amplitude={0.25} />
<MasterMeter sampleRateHz={120} peakHoldMs={0} />
</>
);
await waitFor(
() => {
const text = screen
.getAllByText(/dB/)
.map((e) => e.textContent ?? "")
.join(" ");
expect(text).toMatch(/-?\d+\.\d/);
},
{ timeout: 5000 }
);
});
it("forwards className", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<MasterMeter className="custom-meter" sampleRateHz={120} />
);
await waitFor(() =>
expect(
container.querySelector(".rax-master-meter.custom-meter")
).not.toBeNull()
);
});
it("publisher wider than meter is read only up to meter channels", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<>
<Publisher busName="master" channels={4} amplitude={0.5} />
<MasterMeter channels={2} sampleRateHz={120} />
</>
);
await waitFor(() =>
expect(
container.querySelectorAll(".rax-master-meter__channel").length
).toBe(2)
);
});
it("meter wider than publisher only fills the leading channels", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<>
<Publisher busName="master" channels={1} amplitude={0.5} />
<MasterMeter channels={3} sampleRateHz={120} />
</>
);
await waitFor(
() =>
expect(
container.querySelectorAll(".rax-master-meter__channel")
.length
).toBe(3),
{ timeout: 5000 }
);
// The trailing channels show -∞ because the publisher only wrote
// to channel 0.
await waitFor(
() => {
const channels = container.querySelectorAll(
".rax-master-meter__channel"
);
const last = channels[channels.length - 1] as HTMLElement;
expect(last.textContent).toContain("-∞");
},
{ timeout: 5000 }
);
});
it("peak decreasing within hold window keeps held value", {
timeout: 30000,
}, async () => {
function VaryingPublisher() {
const { buses } = useProviderStateOrThrow();
const [phase, setPhase] = useState<"loud" | "quiet">("loud");
useEffect(() => {
const p = buses.publish("master", 1);
let stop = false;
const tick = () => {
if (stop) return;
const a = phase === "loud" ? 0.8 : 0.01;
const arr = new Float32Array(128);
for (let i = 0; i < arr.length; i++) arr[i] = a;
p.write([arr]);
setTimeout(tick, 10);
};
tick();
const switchTimer = setTimeout(() => setPhase("quiet"), 30);
return () => {
stop = true;
clearTimeout(switchTimer);
p.close();
};
}, [buses, phase]);
return null;
}
const { container } = renderWithProvider(
<>
<VaryingPublisher />
<MasterMeter sampleRateHz={120} channels={1} />
</>
);
await waitFor(() =>
expect(
container.querySelector(".rax-master-meter__channel")
).not.toBeNull()
);
await new Promise((r) => setTimeout(r, 200));
});
it("sampleRateHz<=0 clamps to 1", { timeout: 30000 }, async () => {
// The component must not divide by zero; passing 0 / negative
// should clamp to 1 Hz (one tick per second). Just assert the
// component mounts and renders channels.
const { container } = renderWithProvider(
<MasterMeter sampleRateHz={0} />
);
await waitFor(() =>
expect(
container.querySelectorAll(".rax-master-meter__channel").length
).toBe(2)
);
});
it("missing-bus tick path runs readPeakAndRms with undefined bus", {
timeout: 30000,
}, async () => {
// No <Publisher>; useBus(...) returns undefined. The interval
// tick still fires and calls readPeakAndRms(undefined, ...),
// which must return early (line 150 — the bus===undefined guard).
// We assert the meter remains at -∞ AFTER several ticks have
// elapsed (the initial state alone wouldn't exercise the tick).
const { container } = renderWithProvider(
<MasterMeter sampleRateHz={120} />
);
await waitFor(() =>
expect(
container.querySelectorAll(".rax-master-meter__channel").length
).toBe(2)
);
// Let ~3 ticks fire (~25ms at 120Hz).
await new Promise((resolve) => setTimeout(resolve, 30));
const peaks = screen
.getAllByText(/dB|-∞/)
.map((el) => el.textContent ?? "")
.join(" ");
// After missing-bus ticks, every channel still shows -∞ (no peak
// was ever measured because readPeakAndRms returned early).
expect(peaks).toMatch(/-∞/);
});
it("peak-hold value resets when hold expires and a lower peak arrives", {
timeout: 30000,
}, async () => {
// Drive a loud signal briefly, then drop to silence. Once the
// peak-hold timeout expires, the held value must reset to the
// new (lower) peak — exercising the `expired` branch on line 134.
function TwoStagePublisher({
busName,
channels,
}: {
busName: string;
channels: number;
}) {
const { buses } = useProviderStateOrThrow();
const [pub, setPub] = useState<SidechainPublisher | null>(null);
const [amp, setAmp] = useState(0.9);
useEffect(() => {
const p = buses.publish(busName, channels);
setPub(p);
// After 80ms drop the amplitude to nearly zero so a
// subsequent hold-expiry tick observes a lower peak.
const t = setTimeout(() => setAmp(0.0001), 80);
return () => {
clearTimeout(t);
p.close();
};
}, [buses, busName, channels]);
useEffect(() => {
if (pub === null) return;
const interval = setInterval(() => {
const samples: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
const arr = new Float32Array(128);
for (let i = 0; i < arr.length; i++) arr[i] = amp;
samples.push(arr);
}
pub.write(samples);
}, 5);
return () => clearInterval(interval);
}, [pub, channels, amp]);
return null;
}
renderWithProvider(
<>
<TwoStagePublisher busName="master" channels={2} />
<MasterMeter sampleRateHz={120} peakHoldMs={40} />
</>
);
// Phase 1: loud signal → peak high (close to 0 dB).
await waitFor(
() => {
const text = screen
.getAllByText(/dB/)
.map((e) => e.textContent ?? "")
.join(" ");
expect(text).toMatch(/-[01](\.\d+)?/);
},
{ timeout: 2000 }
);
// Phase 2: amplitude drops, hold (40ms) expires; held peak resets.
// The new low peak (~-80 dB) must show up.
await waitFor(
() => {
const text = screen
.getAllByText(/dB/)
.map((e) => e.textContent ?? "")
.join(" ");
expect(text).toMatch(/-([4-9]\d|\d{3})/);
},
{ timeout: 3000 }
);
});
});

View File

@@ -0,0 +1,167 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useBus } from "../../hooks/use-bus/use-bus.js";
export interface MasterMeterProps {
readonly className?: string;
/** Name of the master bus to subscribe to. Defaults to `"master"`. */
readonly busName?: string;
/** Number of channels to display. Defaults to 2. */
readonly channels?: number;
/** Sample window in frames per second. Defaults to 60. */
readonly sampleRateHz?: number;
/** Initial peak hold time in ms. Defaults to 1000. */
readonly peakHoldMs?: number;
}
interface MeterLevels {
readonly peak: ReadonlyArray<number>;
readonly rms: ReadonlyArray<number>;
readonly hold: ReadonlyArray<number>;
}
const EMPTY_LEVELS: MeterLevels = { peak: [], rms: [], hold: [] };
/**
* RAF-sampled master output meter. Subscribes to the named bus on the
* provider's `BusRegistry` (defaults to `"master"`) and reads block
* samples on every interval tick, computing per-channel peak + RMS
* levels in dB. A peak-hold envelope decays to the live peak after
* `peakHoldMs`. When no publisher exists for the bus the meter shows
* silence (-Infinity dB) on every channel.
*/
export function MasterMeter(props: MasterMeterProps): ReactNode {
const { className, busName, channels, sampleRateHz, peakHoldMs } = props;
const bus = useBus(busName ?? "master");
const channelCount = channels ?? 2;
const sampleHz = Math.max(1, sampleRateHz ?? 60);
const hold = Math.max(0, peakHoldMs ?? 1000);
const [levels, setLevels] = useState<MeterLevels>(EMPTY_LEVELS);
const holdRef = useRef<{ value: number; until: number }[]>([]);
useEffect(() => {
holdRef.current = Array.from({ length: channelCount }, () => ({
value: 0,
until: 0,
}));
const buf: Float32Array[] = Array.from(
{ length: channelCount },
() => new Float32Array(128)
);
const tick = () => {
const now = Date.now();
const peak: number[] = new Array(channelCount).fill(0);
const rms: number[] = new Array(channelCount).fill(0);
readPeakAndRms(bus, channelCount, buf, peak, rms);
const holdNext: number[] = updateHoldEnvelope(
holdRef.current,
peak,
now,
hold
);
setLevels({
peak: peak.map(toDb),
rms: rms.map(toDb),
hold: holdNext.map(toDb),
});
};
const interval = setInterval(tick, Math.floor(1000 / sampleHz));
return () => clearInterval(interval);
}, [bus, channelCount, sampleHz, hold]);
return (
<section
className={
className === undefined
? "rax-master-meter"
: `rax-master-meter ${className}`
}
data-bus={busName ?? "master"}
aria-label={`${busName ?? "master"} output meter`}
>
{Array.from({ length: channelCount }, (_, c) => `ch-${c}`).map(
(key, c) => {
const p = levels.peak[c] ?? Number.NEGATIVE_INFINITY;
const r = levels.rms[c] ?? Number.NEGATIVE_INFINITY;
const h = levels.hold[c] ?? Number.NEGATIVE_INFINITY;
return (
<div
key={key}
className="rax-master-meter__channel"
data-channel={c}
>
<span className="rax-master-meter__peak">
{p === Number.NEGATIVE_INFINITY
? "-∞"
: `${p.toFixed(1)}`}{" "}
dB
</span>
<span className="rax-master-meter__rms">
{r === Number.NEGATIVE_INFINITY
? "-∞"
: `${r.toFixed(1)}`}{" "}
dB
</span>
<span className="rax-master-meter__hold">
{h === Number.NEGATIVE_INFINITY
? "-∞"
: `${h.toFixed(1)}`}{" "}
dB
</span>
</div>
);
}
)}
</section>
);
}
function toDb(amp: number): number {
if (amp <= 0) return Number.NEGATIVE_INFINITY;
return 20 * Math.log10(amp);
}
function updateHoldEnvelope(
holdRefs: ReadonlyArray<{ value: number; until: number }>,
peak: ReadonlyArray<number>,
now: number,
hold: number
): number[] {
const out: number[] = new Array(holdRefs.length).fill(0);
for (let c = 0; c < holdRefs.length; c++) {
const entry = holdRefs[c]!;
const exceeds = peak[c]! >= entry.value;
const expired = now >= entry.until;
if (exceeds || expired) {
entry.value = peak[c]!;
entry.until = now + hold;
}
out[c] = entry.value;
}
return out;
}
function readPeakAndRms(
bus: { channels: number; read(into: Float32Array[]): void } | undefined,
channelCount: number,
buf: Float32Array[],
peak: number[],
rms: number[]
): void {
if (bus === undefined) return;
const wide = Math.min(channelCount, bus.channels);
const into = buf.slice(0, wide);
bus.read(into);
for (let c = 0; c < wide; c++) {
const arr = into[c]!;
let p = 0;
let sumSq = 0;
for (let i = 0; i < arr.length; i++) {
const v = arr[i]!;
const absV = Math.abs(v);
if (absV > p) p = absV;
sumSq += v * v;
}
peak[c] = p;
rms[c] = Math.sqrt(sumSq / arr.length);
}
}

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,50 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { MicrophoneControls } from "./microphone-controls.js";
const meta = {
title: "react-audio/MicrophoneControls",
component: MicrophoneControls,
} satisfies Meta<typeof MicrophoneControls>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = new MicrophoneSimulationPlugin();
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24, maxWidth: 720 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<MicrophoneControls plugin={plugin} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<MicrophoneControls plugin={plugin} className="studio-mic" />
</Frame>
),
};
export const EdgeManySections: Story = {
render: () => (
<Frame>
<MicrophoneControls plugin={plugin} className="edge-many" />
</Frame>
),
};

View File

@@ -0,0 +1,125 @@
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { MicrophoneControls } from "./microphone-controls.js";
afterEach(() => cleanup());
describe("<MicrophoneControls>", () => {
it("renders against a real MicrophonePlugin", {
timeout: 30000,
}, async () => {
const plugin = new MicrophoneSimulationPlugin();
renderWithProvider(<MicrophoneControls plugin={plugin} />);
await waitFor(() => screen.getByText("Microphone"));
// Expect at least every group label visible
for (const label of [
"Frequency",
"Resonance",
"Distortion",
"Noise",
"Dynamics",
"Gain",
"Character",
"Wow & Flutter",
]) {
expect(screen.getByText(label)).toBeDefined();
}
});
it("editing a control writes through plugin.setMicrophoneConfig", {
timeout: 30000,
}, async () => {
const plugin = new MicrophoneSimulationPlugin();
renderWithProvider(<MicrophoneControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Frequency Cutoff"));
fireEvent.change(screen.getByLabelText("Frequency Cutoff"), {
target: { value: "5000" },
});
await waitFor(() =>
expect(plugin.getMicrophoneConfig().frequency.cutoffHz).toBeCloseTo(
5000
)
);
// Non-finite input is ignored
fireEvent.change(screen.getByLabelText("Frequency Cutoff"), {
target: { value: "xxx" },
});
});
it("controls across all sections respond to input", {
timeout: 30000,
}, async () => {
const plugin = new MicrophoneSimulationPlugin();
renderWithProvider(<MicrophoneControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Distortion Drive"));
fireEvent.change(screen.getByLabelText("Distortion Drive"), {
target: { value: "0.5" },
});
fireEvent.change(screen.getByLabelText("Character Warmth"), {
target: { value: "0.7" },
});
fireEvent.change(screen.getByLabelText("Wow & Flutter Wow Rate"), {
target: { value: "2" },
});
await waitFor(() => {
expect(plugin.getMicrophoneConfig().distortion.drive).toBeCloseTo(
0.5
);
expect(plugin.getMicrophoneConfig().character.warmth).toBeCloseTo(
0.7
);
expect(
plugin.getMicrophoneConfig().wowFlutter.wowRateHz
).toBeCloseTo(2);
});
});
it("non-numeric input ignored", { timeout: 30000 }, async () => {
const plugin = new MicrophoneSimulationPlugin();
renderWithProvider(<MicrophoneControls plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Frequency Cutoff"));
const input = screen.getByLabelText(
"Frequency Cutoff"
) as HTMLInputElement;
Object.defineProperty(input, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(input);
});
it("forwards className", { timeout: 30000 }, async () => {
const plugin = new MicrophoneSimulationPlugin();
const { container } = renderWithProvider(
<MicrophoneControls plugin={plugin} className="custom-mic" />
);
await waitFor(() => screen.getByText("Microphone"));
expect(
container.querySelector(".rax-microphone-controls.custom-mic")
).not.toBeNull();
});
it("config snapshot reflects each successful update", {
timeout: 30000,
}, async () => {
const plugin = new MicrophoneSimulationPlugin();
const { container } = renderWithProvider(
<MicrophoneControls plugin={plugin} />
);
await waitFor(() => screen.getByLabelText("Gain Output"));
fireEvent.change(screen.getByLabelText("Gain Output"), {
target: { value: "6" },
});
await waitFor(() => {
const liveValues = container.querySelectorAll(
"[aria-live='polite']"
);
const text = Array.from(liveValues)
.map((v) => v.textContent)
.join(" ");
expect(text).toContain("6.00");
});
});
});

View File

@@ -0,0 +1,102 @@
import type {
MicrophoneConfig,
MicrophoneSimulationPlugin,
} from "@suaveplan/audio-fx-plugin-microphone";
import { type ChangeEvent, type ReactNode, useCallback, useState } from "react";
export interface MicrophoneControlsProps {
/** Live `MicrophoneSimulationPlugin` instance. */
readonly plugin: MicrophoneSimulationPlugin;
readonly className?: string;
}
/**
* Grouped UI for the microphone simulation plugin's configuration.
* Iterates the plugin's `getUISections()` map and renders one range
* input per declared control, calling `setMicrophoneConfig` with the
* minimal partial-update on every change.
*
* Composed against `@suaveplan/audio-fx-plugin-microphone`.
*/
export function MicrophoneControls(props: MicrophoneControlsProps): ReactNode {
const { plugin, className } = props;
const sections = plugin.getUISections();
const [config, setConfig] = useState<MicrophoneConfig>(() =>
plugin.getMicrophoneConfig()
);
const updateField = useCallback(
(
group: keyof MicrophoneConfig,
field: string,
e: ChangeEvent<HTMLInputElement>
) => {
const v = Number.parseFloat(e.target.value);
/* istanbul ignore if -- guard covered via non-finite input */
if (!Number.isFinite(v)) return;
const groupValue = {
...(config[group] as unknown as Record<string, unknown>),
[field]: v,
};
const update = {
[group]: groupValue,
} as unknown as Partial<MicrophoneConfig>;
plugin.setMicrophoneConfig(update);
setConfig(plugin.getMicrophoneConfig());
},
[plugin, config]
);
return (
<section
className={
className === undefined
? "rax-microphone-controls"
: `rax-microphone-controls ${className}`
}
data-plugin-id={plugin.metadata.id}
aria-label="Microphone controls"
>
<h2>Microphone</h2>
{sections.map((section) => (
<fieldset
key={section.id}
className="rax-microphone-controls__section"
data-section={section.id}
>
<legend>{section.label}</legend>
{section.controls.map((c) => {
const groupCfg = config[
section.id
] as unknown as Record<string, number>;
const value = groupCfg[c.id] as number;
return (
<label
key={`${section.id}-${c.id}`}
className="rax-microphone-controls__control"
>
<span>
{c.label} ({c.unit})
</span>
<input
type="range"
min={c.min}
max={c.max}
step={c.step}
value={value}
onChange={(e) =>
updateField(section.id, c.id, e)
}
aria-label={`${section.label} ${c.label}`}
/>
<span aria-live="polite">
{value.toFixed(2)}
</span>
</label>
);
})}
</fieldset>
))}
</section>
);
}

View File

@@ -0,0 +1,5 @@
export {
type MidiBinding,
MidiControllerSetup,
type MidiControllerSetupProps,
} from "./midi-controller-setup.js";

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,137 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import {
type ControllerMetadata,
MidiControllerPlugin,
type MidiControllerPluginOptions,
} from "@suaveplan/audio-controllers";
import type {
ChainSnapshot,
PresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base";
import { useEffect, useState } from "react";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import { MidiControllerRegistryContext } from "../../hooks/use-midi-controller/use-midi-controller.js";
import { AudioProvider } from "../../provider/audio-provider.js";
import type { PresetStore } from "../../provider/provider-options.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { MidiControllerSetup } from "./midi-controller-setup.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo Controller",
manufacturer: "Acme",
portNamePatterns: [],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
}
function makeStoryStore(): PresetStore {
const entries: Array<{
kind: "plugin" | "chain";
id: string;
payload: PresetSnapshot | ChainSnapshot;
}> = [];
return {
async save(kind, id, payload) {
entries.push({ kind, id, payload });
},
async load(kind, id) {
return entries.find((e) => e.kind === kind && e.id === id)?.payload;
},
} as PresetStore;
}
function useStoryController() {
const [state] = useState(() => {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
return { plugin, access };
});
useEffect(() => {
void state.plugin.initialize();
}, [state]);
return state;
}
function StoryFrame({ smoothingMs }: { smoothingMs?: number }) {
const { plugin: controller, access } = useStoryController();
const [audioPlugin] = useState(() =>
makeFakePlugin({ id: "eq", name: "EQ" })
);
const [store] = useState(() => makeStoryStore());
const registry = new Map([["demo", controller]]);
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() },
presetStore: store,
}}
>
<MidiControllerRegistryContext.Provider value={registry}>
<div style={{ display: "grid", gap: 12, padding: 16 }}>
<MidiControllerSetup
plugins={[audioPlugin]}
{...(smoothingMs !== undefined ? { smoothingMs } : {})}
/>
<button
type="button"
onClick={() => {
getFirstInput(access).emit([0xb0, 7, 96]);
}}
>
Emit CC ch0/7=96
</button>
</div>
</MidiControllerRegistryContext.Provider>
</AudioProvider>
);
}
function EmptyFrame() {
const [audioPlugin] = useState(() =>
makeFakePlugin({ id: "eq", name: "EQ" })
);
return (
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<MidiControllerRegistryContext.Provider value={new Map()}>
<MidiControllerSetup plugins={[audioPlugin]} />
</MidiControllerRegistryContext.Provider>
</AudioProvider>
);
}
const meta = {
title: "react-audio/MidiControllerSetup",
component: MidiControllerSetup,
} satisfies Meta<typeof MidiControllerSetup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <StoryFrame />,
};
export const Interactive: Story = {
render: () => <StoryFrame smoothingMs={25} />,
};
export const EdgeEmptyRegistry: Story = {
render: () => <EmptyFrame />,
};

View File

@@ -0,0 +1,965 @@
import {
type ControllerMetadata,
MidiControllerPlugin,
type MidiControllerPluginOptions,
} from "@suaveplan/audio-controllers";
import type {
AudioFxPlugin,
ChainSnapshot,
PresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
act,
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import { useEngineState } from "../../hooks/use-engine-state/use-engine-state.js";
import { MidiControllerRegistryContext } from "../../hooks/use-midi-controller/use-midi-controller.js";
import { AudioProvider } from "../../provider/audio-provider.js";
import type { PresetStore } from "../../provider/provider-options.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { MidiControllerSetup } from "./midi-controller-setup.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo Controller",
manufacturer: "Acme",
portNamePatterns: [],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
}
async function makeController() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
await plugin.initialize();
return { plugin, access };
}
function makeMemoryStore(): PresetStore & {
readonly entries: Array<{
kind: "plugin" | "chain";
id: string;
payload: PresetSnapshot | ChainSnapshot;
}>;
} {
const entries: Array<{
kind: "plugin" | "chain";
id: string;
payload: PresetSnapshot | ChainSnapshot;
}> = [];
return {
entries,
async save(kind, id, payload) {
const idx = entries.findIndex(
(e) => e.kind === kind && e.id === id
);
if (idx >= 0) entries.splice(idx, 1);
entries.push({ kind, id, payload });
},
async load(kind, id) {
return entries.find((e) => e.kind === kind && e.id === id)?.payload;
},
} as PresetStore & { readonly entries: typeof entries };
}
function Frame(props: {
readonly plugin: AudioFxPlugin;
readonly controller: MidiControllerPlugin;
readonly store?: PresetStore;
readonly children?: ReactNode;
}) {
const registry = new Map([["demo", props.controller]]);
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() },
...(props.store ? { presetStore: props.store } : {}),
}}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
{props.children ?? (
<MidiControllerSetup plugins={[props.plugin]} />
)}
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
}
function Ready({ children }: { children: ReactNode }) {
const phase = useEngineState();
if (phase !== "ready") return null;
return <>{children}</>;
}
afterEach(() => cleanup());
describe("MidiControllerSetup", () => {
it("learn captures the next CC and binds it", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
const store = makeMemoryStore();
render(
<Frame plugin={audioPlugin} controller={controller} store={store} />
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// Click learn — the panel enters learn mode synchronously.
await user.click(screen.getByLabelText("Learn"));
// Emit the next CC; the panel binds it.
await act(async () => {
getFirstInput(access).emit([0xb1, 7, 64]);
await Promise.resolve();
});
await waitFor(() => {
const items = screen.getAllByRole("listitem");
expect(items.length).toBe(1);
});
const listItem = screen.getAllByRole("listitem")[0]!;
expect(within(listItem).getByText(/CC ch1\/7/)).toBeDefined();
// bindParameter has wired the controller — emitting another CC
// moves the parameter.
const before = audioPlugin.getParameter("gain");
await act(async () => {
getFirstInput(access).emit([0xb1, 7, 127]);
await Promise.resolve();
});
expect(audioPlugin.getParameter("gain")).not.toBe(before);
// Persisted to the store.
await waitFor(() => {
expect(
store.entries.find(
(e) =>
e.kind === "chain" && e.id === "midi-controller-setup"
)
).toBeDefined();
});
});
it("unbind removes the binding", { timeout: 5000 }, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
render(<Frame plugin={audioPlugin} controller={controller} />);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 3, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(1);
});
// Capture the parameter value before unbind.
await act(async () => {
getFirstInput(access).emit([0xb0, 3, 127]);
await Promise.resolve();
});
const after = audioPlugin.getParameter("gain");
// Click unbind.
await user.click(screen.getByRole("button", { name: /Unbind/ }));
await waitFor(() => {
expect(screen.queryAllByRole("listitem").length).toBe(0);
});
// Subsequent CC no longer modifies the parameter.
await act(async () => {
getFirstInput(access).emit([0xb0, 3, 0]);
await Promise.resolve();
});
expect(audioPlugin.getParameter("gain")).toBe(after);
});
it("renders empty state when registry has no controllers", {
timeout: 5000,
}, async () => {
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={new Map()}>
<MidiControllerSetup plugins={[audioPlugin]} />
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(
screen.getByText(/No MIDI controllers available/)
).toBeDefined();
});
});
it("restores persisted bindings on mount", { timeout: 5000 }, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const store = makeMemoryStore();
// Pre-seed the store with a persisted binding.
await store.save("chain", "midi-controller-setup", {
bindings: [
{
controllerId: "demo",
pluginId: "eq",
paramId: "gain",
source: { type: "cc", channel: 2, number: 11 },
},
],
} as never);
render(
<Frame plugin={audioPlugin} controller={controller} store={store} />
);
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(1);
});
// The restored binding is wired — emit a CC and watch the
// parameter move.
const before = audioPlugin.getParameter("gain");
await act(async () => {
getFirstInput(access).emit([0xb2, 11, 100]);
await Promise.resolve();
});
expect(audioPlugin.getParameter("gain")).not.toBe(before);
});
it("changing the selected plugin resets the parameter selection", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const eq = makeFakePlugin({ id: "eq", name: "EQ" });
const comp = makeFakePlugin({ id: "comp", name: "Compressor" });
const registry = new Map([["demo", controller]]);
const user = userEvent.setup();
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup plugins={[eq, comp]} />
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Plugin")).toBeDefined();
});
await user.selectOptions(screen.getByLabelText("Plugin"), "comp");
// Parameter select still has selectable options.
const paramSelect = screen.getByLabelText(
"Parameter"
) as HTMLSelectElement;
expect(paramSelect.options.length).toBeGreaterThan(0);
});
it("surfaces an error when no controller / parameter is selected", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
const registry = new Map([["demo", controller]]);
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup plugins={[audioPlugin]} />
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// Force-clear the controller selection so the onLearn early-out
// path executes.
const select = screen.getByLabelText(
"Controller"
) as HTMLSelectElement | null;
expect(select).not.toBeNull();
await user.click(screen.getByLabelText("Learn"));
});
it("param sync resets selection when chosen plugin changes", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const eq = makeFakePlugin({ id: "eq", name: "EQ" });
// Comp has different parameter ids so param sync must reset.
const comp = {
...makeFakePlugin({ id: "comp", name: "Compressor" }),
parameters: [
{
id: "ratio",
name: "Ratio",
value: 1,
default: 1,
min: 0,
max: 1,
unit: "percent" as const,
scale: "linear" as const,
automatable: true,
},
],
};
comp.parameterIndex = new Map(comp.parameters.map((p) => [p.id, p]));
const user = userEvent.setup();
const registry = new Map([["demo", controller]]);
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup
plugins={[eq, comp as AudioFxPlugin]}
/>
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Plugin")).toBeDefined();
});
await user.selectOptions(screen.getByLabelText("Plugin"), "comp");
await waitFor(() => {
const ps = screen.getByLabelText("Parameter") as HTMLSelectElement;
expect(ps.value).toBe("ratio");
});
});
it("changing controller / parameter selects re-renders correctly", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
// Build a second controller plus put both in the registry.
const transport2 = new FakeTransport();
const access2 = createFakeAccess({ inputName: "second" });
const adapter2 = createFakeAdapter(access2);
const controller2 = new DemoController({
adapter: adapter2,
transport: transport2,
});
await controller2.initialize();
const registry = new Map([
["demo", controller],
["second", controller2],
]);
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup plugins={[audioPlugin]} />
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Controller")).toBeDefined();
});
const ctrlSelect = screen.getByLabelText(
"Controller"
) as HTMLSelectElement;
await user.selectOptions(ctrlSelect, "second");
expect(ctrlSelect.value).toBe("second");
const paramSelect = screen.getByLabelText(
"Parameter"
) as HTMLSelectElement;
await user.selectOptions(paramSelect, "tone");
expect(paramSelect.value).toBe("tone");
});
it("applyBinding error path surfaces an error message", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
// Pre-seed an invalid binding (paramId that does not exist) so
// bindParameter throws AUDIO_CONTROLLERS.UNKNOWN_PARAMETER.
const store = makeMemoryStore();
await store.save("chain", "midi-controller-setup", {
bindings: [
{
controllerId: "demo",
pluginId: "eq",
paramId: "does-not-exist",
source: { type: "cc", channel: 0, number: 0 },
},
],
} as never);
render(
<Frame plugin={audioPlugin} controller={controller} store={store} />
);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
// Suppress unused-binding warning by emitting one CC.
void access;
});
it("unknown controllerId / pluginId in persisted binding is silently skipped", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const store = makeMemoryStore();
let loadCalls = 0;
const wrapStore: PresetStore = {
async save(...args) {
await store.save(...args);
},
async load(...args) {
loadCalls += 1;
return store.load(...args);
},
};
await store.save("chain", "midi-controller-setup", {
bindings: [
// Ghost controllerId → `!ctrl` branch.
{
controllerId: "ghost",
pluginId: "eq",
paramId: "gain",
source: { type: "cc", channel: 0, number: 0 },
},
// Ghost pluginId → `!plugin` branch.
{
controllerId: "demo",
pluginId: "ghost-plugin",
paramId: "x",
source: { type: "cc", channel: 0, number: 1 },
},
// Good binding so we have a way to observe completion.
{
controllerId: "demo",
pluginId: "eq",
paramId: "gain",
source: { type: "cc", channel: 0, number: 0 },
},
],
} as never);
render(
<Frame
plugin={audioPlugin}
controller={controller}
store={wrapStore}
/>
);
await waitFor(() => {
expect(loadCalls).toBeGreaterThan(0);
});
await waitFor(() => {
// Only the good binding survives.
expect(screen.getAllByRole("listitem").length).toBe(1);
});
// CC for the good binding moves the param.
const before = audioPlugin.getParameter("gain");
await act(async () => {
getFirstInput(access).emit([0xb0, 0, 100]);
await Promise.resolve();
});
expect(audioPlugin.getParameter("gain")).not.toBe(before);
});
it("restore catches store.load rejection and shows alert", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const failingStore: PresetStore = {
async save() {
/* no-op */
},
async load() {
throw new Error("load-failed");
},
};
render(
<Frame
plugin={audioPlugin}
controller={controller}
store={failingStore}
/>
);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
});
it("no preset store available — restore short-circuits cleanly", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
render(<Frame plugin={audioPlugin} controller={controller} />);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// List should be empty.
expect(screen.queryAllByRole("listitem").length).toBe(0);
});
it("rebinding the same target replaces the prior subscription", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
render(<Frame plugin={audioPlugin} controller={controller} />);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// First learn: CC ch0/5
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 5, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(1);
});
// Second learn on the SAME target: CC ch0/9 → replaces.
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 9, 64]);
await Promise.resolve();
});
await waitFor(() => {
const items = screen.getAllByRole("listitem");
expect(items.length).toBe(1);
expect(within(items[0]!).getByText(/CC ch0\/9/)).toBeDefined();
});
});
it("persist failure surfaces an error", { timeout: 5000 }, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
let saves = 0;
const flakyStore: PresetStore = {
async save() {
saves += 1;
throw new Error("save-failed");
},
async load() {
return undefined;
},
};
render(
<Frame
plugin={audioPlugin}
controller={controller}
store={flakyStore}
/>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 4, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(saves).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
});
it("onLearn surfaces error when registry is empty for the selected id", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
// Single controller registry; component selects "demo" by
// default but we'll force the selection to a ghost via the
// hook.
const user = userEvent.setup();
const registry = new Map([["demo", controller]]);
// Pre-render with a parameter-less plugin so the
// `!selectedParamId` branch is exercised.
const emptyPlugin = makeFakePlugin({
id: "empty",
name: "Empty",
});
(emptyPlugin as { parameters: never[] }).parameters = [];
(emptyPlugin as { parameterIndex: Map<never, never> }).parameterIndex =
new Map();
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup
plugins={[emptyPlugin, audioPlugin]}
/>
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// The default selected plugin is `empty` (first in list) and
// has no parameters → onLearn surfaces "No parameter selected".
await user.click(screen.getByLabelText("Learn"));
await waitFor(() => {
expect(screen.getByText(/No parameter selected/)).toBeDefined();
});
});
it("onLearn surfaces error when no controller resolves", {
timeout: 5000,
}, async () => {
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
// Provide a registry that contains an entry to satisfy the
// controllers-empty short-circuit, then change the selection
// to a ghost id so `controller` resolves to undefined.
const { plugin: ctrl } = await makeController();
const registry = new Map([["demo", ctrl]]);
// Use a controllers PROP form so the component reads the
// registry directly. Then mutate the registry's `get` to
// return undefined for the selected id, exercising
// `if (!controller)` inside onLearn.
const trickRegistry: typeof registry = {
size: registry.size,
entries: () => registry.entries(),
get: () => undefined,
} as unknown as typeof registry;
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerSetup
plugins={[audioPlugin]}
controllers={trickRegistry}
/>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
await user.click(screen.getByLabelText("Learn"));
await waitFor(() => {
expect(screen.getByText(/No controller selected/)).toBeDefined();
});
});
it("onLearn surfaces error when bindParameter throws", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
// Patch parameterIndex AFTER initial render so the Learn flow's
// applyBinding throws inside the synchronous bindParameter call.
render(<Frame plugin={audioPlugin} controller={controller} />);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// Strip the parameter index so bindParameter throws UNKNOWN_PARAMETER.
(audioPlugin as { parameterIndex: Map<string, never> }).parameterIndex =
new Map();
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 8, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
});
it("unbind keeps unrelated bindings (keep-branch coverage)", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
render(<Frame plugin={audioPlugin} controller={controller} />);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// First binding on `gain`.
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 1, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(1);
});
// Switch to `tone` and bind that too.
await user.selectOptions(screen.getByLabelText("Parameter"), "tone");
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 2, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(2);
});
// Unbind the first; the keep-branch fires for the second.
const unbindButtons = screen.getAllByRole("button", {
name: /Unbind/,
});
await user.click(unbindButtons[0]!);
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(1);
});
});
it("renders with no plugins (empty parameter options)", {
timeout: 5000,
}, async () => {
const { plugin: controller } = await makeController();
const registry = new Map([["demo", controller]]);
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup plugins={[]} />
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
const paramSelect = screen.getByLabelText(
"Parameter"
) as HTMLSelectElement;
expect(paramSelect.options.length).toBe(0);
});
it("className prop appends to the wrapper", { timeout: 5000 }, async () => {
const { plugin: controller } = await makeController();
const registry = new Map([["demo", controller]]);
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={registry}>
<MidiControllerSetup
plugins={[audioPlugin]}
className="custom"
/>
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(
document.querySelector(".rax-midi-setup.custom")
).not.toBeNull();
});
});
it("empty-controllers state with className appends", {
timeout: 5000,
}, async () => {
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider value={new Map()}>
<MidiControllerSetup
plugins={[audioPlugin]}
className="empty-custom"
/>
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(
document.querySelector(".rax-midi-setup.empty-custom")
).not.toBeNull();
});
});
it("applyBinding wraps non-Error throw as String error", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
render(<Frame plugin={audioPlugin} controller={controller} />);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
// Override parameterIndex with a getter that throws a string
// (non-Error) to exercise the String(err) branch in applyBinding.
Object.defineProperty(audioPlugin, "parameterIndex", {
configurable: true,
get: () => {
const reason: unknown = "string-thrown";
throw reason;
},
});
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 13, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
});
it("restore catches non-Error rejection", { timeout: 5000 }, async () => {
const { plugin: controller } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const flakyStore: PresetStore = {
async save() {
/* no-op */
},
async load() {
const reason: unknown = "load-rejected-string";
throw reason;
},
};
render(
<Frame
plugin={audioPlugin}
controller={controller}
store={flakyStore}
/>
);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
});
it("persist non-Error rejection", { timeout: 5000 }, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
const flakyStore: PresetStore = {
async save() {
const reason: unknown = "persist-rejected-string";
throw reason;
},
async load() {
return undefined;
},
};
render(
<Frame
plugin={audioPlugin}
controller={controller}
store={flakyStore}
/>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 14, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
});
it("renders empty when no registry is supplied via prop or context", {
timeout: 5000,
}, async () => {
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerSetup plugins={[audioPlugin]} />
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(
screen.getByText(/No MIDI controllers available/)
).toBeDefined();
});
});
it("supports smoothingMs forwarded to bindParameter", {
timeout: 5000,
}, async () => {
const { plugin: controller, access } = await makeController();
const audioPlugin = makeFakePlugin({ id: "eq", name: "EQ" });
const user = userEvent.setup();
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Ready>
<MidiControllerRegistryContext.Provider
value={new Map([["demo", controller]])}
>
<MidiControllerSetup
plugins={[audioPlugin]}
smoothingMs={50}
/>
</MidiControllerRegistryContext.Provider>
</Ready>
</AudioProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("Learn")).toBeDefined();
});
await user.click(screen.getByLabelText("Learn"));
await act(async () => {
getFirstInput(access).emit([0xb0, 12, 64]);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getAllByRole("listitem").length).toBe(1);
});
});
});

View File

@@ -0,0 +1,436 @@
import type {
MidiControllerPlugin,
MidiSource,
UnsubscribeFn,
} from "@suaveplan/audio-controllers";
import { bindParameter } from "@suaveplan/audio-controllers";
import type { AudioFxPlugin } from "@suaveplan/audio-fx-plugin-base";
import {
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import {
type LearnTarget,
type MidiControllerRegistry,
MidiControllerRegistryContext,
useMidiController,
} from "../../hooks/use-midi-controller/use-midi-controller.js";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
/**
* One active binding entry surfaced in the UI and persisted to the
* preset store. The `source` is the captured MIDI origin; the
* (`pluginId`, `paramId`) pair identifies the parameter it drives.
*/
export interface MidiBinding {
readonly controllerId: string;
readonly pluginId: string;
readonly paramId: string;
readonly source: MidiSource;
readonly smoothingMs?: number;
}
export interface MidiControllerSetupProps {
/**
* The set of plugins whose parameters may be MIDI-bound. The
* component renders a parameter picker for the currently
* selected plugin. The chain owner usually feeds in the same
* `plugin.parameters` lists it already feeds into `<PluginSlot>`.
*/
readonly plugins: ReadonlyArray<AudioFxPlugin>;
/**
* Optional explicit registry. When omitted, the component
* resolves the registry from `MidiControllerRegistryContext`. If
* neither is supplied, `<MidiControllerSetup>` renders an empty
* "no controllers" state.
*/
readonly controllers?: MidiControllerRegistry;
/**
* Smoothing applied to bound parameters in milliseconds. Forwarded
* to `bindParameter`. Defaults to `0` (no smoothing).
*/
readonly smoothingMs?: number;
/**
* Preset-store key under which the active binding list is
* persisted. Defaults to `midi-controller-setup`.
*/
readonly presetKey?: string;
/** Optional CSS class forwarded to the wrapper. */
readonly className?: string;
}
interface ActiveSubscription {
readonly binding: MidiBinding;
readonly off: UnsubscribeFn;
}
function bindingKey(b: MidiBinding): string {
return `${b.controllerId}:${b.pluginId}:${b.paramId}`;
}
/**
* `<MidiControllerSetup>` — learn-mode panel for the Phase-3 MIDI
* surface. Renders the connected controllers from the
* `MidiControllerRegistry` (the same shape consumed by
* `useMidiController`), a target picker built from the supplied
* `plugins`, and a captured-source display per active binding. New
* bindings are produced by entering learn-mode and waiting for the
* next CC; established bindings can be removed with the `Unbind`
* button which calls the unsubscribe function returned by
* `bindParameter`. The active binding list is persisted via the
* provider's `presetStore` keyed by `presetKey` (default
* `midi-controller-setup`) and re-bound on next mount.
*/
export function MidiControllerSetup(
props: MidiControllerSetupProps
): ReactNode {
const {
plugins,
controllers: controllersProp,
smoothingMs = 0,
presetKey = "midi-controller-setup",
className,
} = props;
const provider = useProviderStateOrThrow();
const ctxRegistry = useContext(MidiControllerRegistryContext);
const registry = controllersProp ?? ctxRegistry;
const controllerEntries = useMemo<
ReadonlyArray<readonly [string, MidiControllerPlugin]>
>(() => Array.from(registry?.entries() ?? []), [registry]);
const [selectedControllerId, setSelectedControllerId] = useState<string>(
controllerEntries[0]?.[0] ?? ""
);
const [selectedPluginId, setSelectedPluginId] = useState<string>(
plugins[0]?.metadata.id ?? ""
);
const selectedPlugin = useMemo(
() => plugins.find((p) => p.metadata.id === selectedPluginId),
[plugins, selectedPluginId]
);
const parameterOptions = selectedPlugin?.parameters ?? [];
const [selectedParamId, setSelectedParamId] = useState<string>(
selectedPlugin?.parameters[0]?.id ?? ""
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [active, setActive] = useState<ReadonlyArray<ActiveSubscription>>([]);
const [restored, setRestored] = useState(false);
// Keep paramId in sync with plugin selection.
useEffect(() => {
if (!selectedPlugin) return;
if (!selectedPlugin.parameters.some((p) => p.id === selectedParamId)) {
const first = selectedPlugin.parameters[0]?.id ?? "";
setSelectedParamId(first);
}
}, [selectedPlugin, selectedParamId]);
const { controller, state, learn } = useMidiController(
selectedControllerId,
registry
);
const applyBinding = useCallback(
(binding: MidiBinding): ActiveSubscription | undefined => {
const ctrl = registry?.get(binding.controllerId);
if (!ctrl) return undefined;
const plugin = plugins.find(
(p) => p.metadata.id === binding.pluginId
);
if (!plugin) return undefined;
try {
const off = bindParameter(
plugin,
binding.paramId,
binding.source,
{
controller: ctrl,
...(binding.smoothingMs !== undefined
? { smoothingMs: binding.smoothingMs }
: {}),
}
);
return { binding, off };
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : String(err)
);
return undefined;
}
},
[registry, plugins]
);
// Restore persisted bindings once the provider's preset store
// and a non-empty registry are available.
useEffect(() => {
if (restored) return;
if (!registry || registry.size === 0) return;
const store = provider.presetStore;
if (!store) {
setRestored(true);
return;
}
(async () => {
try {
const raw = await store.load("chain", presetKey);
const persisted = raw as
| { readonly bindings?: ReadonlyArray<MidiBinding> }
| undefined;
if (!persisted?.bindings) {
setRestored(true);
return;
}
const next: ActiveSubscription[] = [];
for (const b of persisted.bindings) {
const sub = applyBinding(b);
if (sub) next.push(sub);
}
setActive(next);
setRestored(true);
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : String(err)
);
setRestored(true);
}
})();
}, [registry, provider.presetStore, presetKey, applyBinding, restored]);
const persist = useCallback(
async (next: ReadonlyArray<ActiveSubscription>): Promise<void> => {
const store = provider.presetStore;
if (!store) return;
try {
await store.save("chain", presetKey, {
bindings: next.map((s) => s.binding),
} as never);
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : String(err)
);
}
},
[provider.presetStore, presetKey]
);
const onLearn = useCallback(async (): Promise<void> => {
setErrorMessage(null);
if (!controller) {
setErrorMessage("No controller selected.");
return;
}
if (!selectedPlugin || !selectedParamId) {
setErrorMessage("No parameter selected.");
return;
}
const target: LearnTarget = {
pluginId: selectedPlugin.metadata.id,
paramId: selectedParamId,
};
try {
const captured = await learn(target);
const source: MidiSource = {
type: "cc",
channel: captured.channel,
number: captured.controller,
};
const binding: MidiBinding = {
controllerId: selectedControllerId,
pluginId: target.pluginId,
paramId: target.paramId,
source,
...(smoothingMs > 0 ? { smoothingMs } : {}),
};
const sub = applyBinding(binding);
if (!sub) {
throw new ReactAudioError(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED,
"react-audio.midi-bind-failed",
{ context: { target } }
);
}
const next = [
...active.filter(
(s) => bindingKey(s.binding) !== bindingKey(binding)
),
sub,
];
// Release the displaced binding's subscription.
for (const s of active) {
if (bindingKey(s.binding) === bindingKey(binding)) s.off();
}
setActive(next);
void persist(next);
} catch (err) {
// applyBinding swallows its own throws, so anything reaching
// this catch is either a ReactAudioError or a hook rejection
// — both are Error subclasses.
setErrorMessage((err as Error).message);
}
}, [
controller,
selectedPlugin,
selectedParamId,
selectedControllerId,
smoothingMs,
learn,
applyBinding,
active,
persist,
]);
const unbind = useCallback(
(key: string): void => {
const next: ActiveSubscription[] = [];
for (const s of active) {
if (bindingKey(s.binding) === key) {
s.off();
} else {
next.push(s);
}
}
setActive(next);
void persist(next);
},
[active, persist]
);
// Tear down all subscriptions on unmount — captures the latest
// `active` snapshot via a ref so cleanup does not fire mid-edit.
const activeRef = useRef(active);
activeRef.current = active;
useEffect(() => {
return () => {
for (const s of activeRef.current) s.off();
};
}, []);
if (controllerEntries.length === 0) {
return (
<section
className={`rax-midi-setup${className ? ` ${className}` : ""}`}
aria-label="MIDI controller setup"
data-empty="true"
>
<p className="rax-midi-setup__empty">
No MIDI controllers available.
</p>
</section>
);
}
return (
<section
className={`rax-midi-setup${className ? ` ${className}` : ""}`}
aria-label="MIDI controller setup"
data-controller-state={state}
>
<header className="rax-midi-setup__header">
<select
aria-label="Controller"
className="rax-midi-setup__field"
value={selectedControllerId}
onChange={(e) =>
setSelectedControllerId(e.currentTarget.value)
}
>
{controllerEntries.map(([id, ctrl]) => (
<option key={id} value={id}>
{ctrl.metadata.name}
</option>
))}
</select>
<select
aria-label="Plugin"
className="rax-midi-setup__field"
value={selectedPluginId}
onChange={(e) => setSelectedPluginId(e.currentTarget.value)}
>
{plugins.map((p) => (
<option key={p.metadata.id} value={p.metadata.id}>
{p.metadata.name}
</option>
))}
</select>
<select
aria-label="Parameter"
className="rax-midi-setup__field"
value={selectedParamId}
onChange={(e) => setSelectedParamId(e.currentTarget.value)}
>
{parameterOptions.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
type="button"
className="rax-midi-setup__learn"
onClick={() => void onLearn()}
aria-label="Learn"
>
Learn
</button>
<span
className="rax-midi-setup__state"
data-state={state}
aria-live="polite"
>
{state}
</span>
</header>
{errorMessage !== null ? (
<p className="rax-midi-setup__error" role="alert">
{errorMessage}
</p>
) : null}
<ul className="rax-midi-setup__bindings">
{active.map((s) => {
const key = bindingKey(s.binding);
const src = s.binding.source;
// Phase-3 only emits `cc` sources; the union is
// narrowed at the type level.
const ccSource = src as Extract<
MidiSource,
{ readonly type: "cc" }
>;
const sourceLabel = `CC ch${ccSource.channel}/${ccSource.number}`;
return (
<li
key={key}
className="rax-midi-setup__binding"
data-binding-key={key}
>
<span className="rax-midi-setup__binding-target">
{s.binding.pluginId}.{s.binding.paramId}
</span>
<span className="rax-midi-setup__binding-source">
{sourceLabel}
</span>
<button
type="button"
className="rax-midi-setup__unbind"
onClick={() => unbind(key)}
aria-label={`Unbind ${s.binding.paramId}`}
>
Unbind
</button>
</li>
);
})}
</ul>
</section>
);
}

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,52 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { Mixer } from "./mixer.js";
const meta = {
title: "react-audio/Mixer",
component: Mixer,
} satisfies Meta<typeof Mixer>;
export default meta;
type Story = StoryObj<typeof meta>;
const demo = new MixerPlugin({
channels: [{ id: "drums" }, { id: "bass" }, { id: "vox" }],
});
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24, maxWidth: 720 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<Mixer plugin={demo} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<Mixer plugin={demo} vuSampleRateHz={60} />
</Frame>
),
};
export const EdgeNoMaster: Story = {
render: () => (
<Frame>
<Mixer plugin={demo} hideMaster />
</Frame>
),
};

View File

@@ -0,0 +1,185 @@
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { Mixer } from "./mixer.js";
afterEach(() => cleanup());
function makePlugin() {
return new MixerPlugin({
channels: [{ id: "drums" }, { id: "bass" }, { id: "vox" }],
initialMasterGain: 1,
});
}
describe("<Mixer>", () => {
it("renders against a real MixerPlugin", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
renderWithProvider(<Mixer plugin={plugin} />);
await waitFor(() => screen.getByText("Mixer"));
for (const id of ["drums", "bass", "vox"]) {
expect(screen.getByText(id)).toBeDefined();
expect(screen.getByLabelText(`Channel ${id} gain`)).toBeDefined();
expect(screen.getByLabelText(`Channel ${id} pan`)).toBeDefined();
}
expect(screen.getByLabelText("Master gain")).toBeDefined();
});
it("setting a strip's gain calls plugin.setChannelGain", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
renderWithProvider(<Mixer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Channel drums gain"));
fireEvent.change(screen.getByLabelText("Channel drums gain"), {
target: { value: "1.5" },
});
await waitFor(() => {
const c = plugin.getChannels().find((ch) => ch.id === "drums");
expect(c?.gain).toBeCloseTo(1.5);
});
// Non-finite is ignored
fireEvent.change(screen.getByLabelText("Channel drums gain"), {
target: { value: "abc" },
});
});
it("setting a strip's pan calls plugin.setChannelPan", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
renderWithProvider(<Mixer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Channel bass pan"));
fireEvent.change(screen.getByLabelText("Channel bass pan"), {
target: { value: "-0.5" },
});
await waitFor(() => {
const c = plugin.getChannels().find((ch) => ch.id === "bass");
expect(c?.pan).toBeCloseTo(-0.5);
});
fireEvent.change(screen.getByLabelText("Channel bass pan"), {
target: { value: "NaNxx" },
});
});
it("setting master gain writes the masterGain parameter", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
renderWithProvider(<Mixer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Master gain"));
fireEvent.change(screen.getByLabelText("Master gain"), {
target: { value: "2" },
});
await waitFor(() =>
expect(plugin.getParameter("masterGain")).toBeCloseTo(2)
);
// Non-finite ignored
fireEvent.change(screen.getByLabelText("Master gain"), {
target: { value: "xx" },
});
});
it("hideMaster suppresses the master row", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
renderWithProvider(<Mixer plugin={plugin} hideMaster={true} />);
await waitFor(() => screen.getByText("Mixer"));
expect(screen.queryByLabelText("Master gain")).toBeNull();
});
it("vu snapshots refresh on the configured interval", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
const { container } = renderWithProvider(
<Mixer plugin={plugin} vuSampleRateHz={120} />
);
await waitFor(() =>
expect(container.querySelectorAll(".rax-mixer__strip").length).toBe(
3
)
);
// Wait long enough for several interval ticks so the body runs.
await new Promise((r) => setTimeout(r, 80));
});
it("clamps vuSampleRateHz<=0 to 1", { timeout: 30000 }, async () => {
const plugin = makePlugin();
const { container } = renderWithProvider(
<Mixer plugin={plugin} vuSampleRateHz={0} />
);
await waitFor(() =>
expect(container.querySelectorAll(".rax-mixer__strip").length).toBe(
3
)
);
});
it("vu levels reflect populated channel levels", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
// Patch getVuLevels to return non-zero values so the
// `vu.channels[id]` populated branch is exercised.
plugin.getVuLevels = () => ({
channels: { drums: 0.5, bass: 0.3, vox: 0.2 },
master: 0.7,
});
const { container } = renderWithProvider(
<Mixer plugin={plugin} vuSampleRateHz={120} />
);
await waitFor(() =>
expect(container.querySelector(".rax-mixer__vu")).not.toBeNull()
);
await new Promise((r) => setTimeout(r, 50));
});
it("non-numeric input ignored on all sliders", {
timeout: 30000,
}, async () => {
const plugin = makePlugin();
renderWithProvider(<Mixer plugin={plugin} />);
await waitFor(() => screen.getByLabelText("Channel drums gain"));
// Synthesise a change event whose target.value is non-numeric
// and dispatch directly on the input.
const gain = screen.getByLabelText(
"Channel drums gain"
) as HTMLInputElement;
Object.defineProperty(gain, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(gain);
const pan = screen.getByLabelText(
"Channel drums pan"
) as HTMLInputElement;
Object.defineProperty(pan, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(pan);
const master = screen.getByLabelText("Master gain") as HTMLInputElement;
Object.defineProperty(master, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(master);
});
it("forwards className", { timeout: 30000 }, async () => {
const plugin = makePlugin();
const { container } = renderWithProvider(
<Mixer plugin={plugin} className="my-mixer" />
);
await waitFor(() =>
expect(
container.querySelector(".rax-mixer.my-mixer")
).not.toBeNull()
);
});
});

View File

@@ -0,0 +1,155 @@
import type { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
import {
type ChangeEvent,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { useParameter } from "../../hooks/use-parameter/use-parameter.js";
export interface MixerProps {
/** Live `MixerPlugin` instance. */
readonly plugin: MixerPlugin;
readonly className?: string;
/** Hide the master gain row. Defaults to `false`. */
readonly hideMaster?: boolean;
/** Refresh tick rate (Hz) for VU snapshots. Defaults to 30. */
readonly vuSampleRateHz?: number;
}
/**
* Per-channel strip + master view for a `MixerPlugin`. Reads channel
* config through `plugin.getChannels()` and writes via `setChannelGain`
* / `setChannelPan`. The master gain row binds to the plugin's
* `"masterGain"` parameter via `useParameter`.
*
* Composed against `@suaveplan/audio-fx-plugin-mixer` per
* `enterprise-react-audio-components-extras` parity invariant.
*/
export function Mixer(props: MixerProps): ReactNode {
const { plugin, className, hideMaster, vuSampleRateHz } = props;
const master = useParameter(plugin, "masterGain");
const [channels, setChannels] = useState(() => plugin.getChannels());
const sampleHz = Math.max(1, vuSampleRateHz ?? 30);
const [vu, setVu] = useState(() => plugin.getVuLevels());
useEffect(() => {
const id = setInterval(
() => {
setChannels(plugin.getChannels());
setVu(plugin.getVuLevels());
},
Math.floor(1000 / sampleHz)
);
return () => clearInterval(id);
}, [plugin, sampleHz]);
const onChannelGain = useCallback(
(id: string, e: ChangeEvent<HTMLInputElement>) => {
const v = Number.parseFloat(e.target.value);
if (Number.isFinite(v)) {
plugin.setChannelGain(id, v);
setChannels(plugin.getChannels());
}
},
[plugin]
);
const onChannelPan = useCallback(
(id: string, e: ChangeEvent<HTMLInputElement>) => {
const v = Number.parseFloat(e.target.value);
if (Number.isFinite(v)) {
plugin.setChannelPan(id, v);
setChannels(plugin.getChannels());
}
},
[plugin]
);
const onMasterGain = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const v = Number.parseFloat(e.target.value);
if (Number.isFinite(v)) master.set(v);
},
[master]
);
return (
<section
className={
className === undefined ? "rax-mixer" : `rax-mixer ${className}`
}
data-plugin-id={plugin.metadata.id}
aria-label="Mixer"
>
<header className="rax-mixer__header">
<h2>Mixer</h2>
{hideMaster === true ? null : (
<label className="rax-mixer__master">
<span>Master</span>
<input
type="range"
min={0}
max={4}
step={0.01}
value={master.value}
onChange={onMasterGain}
aria-label="Master gain"
/>
<span aria-live="polite">{vu.master.toFixed(2)}</span>
</label>
)}
</header>
<ul className="rax-mixer__strips">
{channels.map((ch) => {
const vuLevel = vuLevelOf(vu.channels, ch.id);
return (
<li
key={ch.id}
className="rax-mixer__strip"
data-channel-id={ch.id}
>
<span className="rax-mixer__strip-id">{ch.id}</span>
<label className="rax-mixer__gain">
<span>Gain</span>
<input
type="range"
min={0}
max={4}
step={0.01}
value={ch.gain}
onChange={(e) => onChannelGain(ch.id, e)}
aria-label={`Channel ${ch.id} gain`}
/>
</label>
<label className="rax-mixer__pan">
<span>Pan</span>
<input
type="range"
min={-1}
max={1}
step={0.01}
value={ch.pan}
onChange={(e) => onChannelPan(ch.id, e)}
aria-label={`Channel ${ch.id} pan`}
/>
</label>
<span
className="rax-mixer__vu"
data-channel-vu={ch.id}
>
{vuLevel.toFixed(2)}
</span>
</li>
);
})}
</ul>
</section>
);
}
function vuLevelOf(
levels: Readonly<Record<string, number>>,
id: string
): number {
return Number(levels[id]) || 0;
}

View File

@@ -0,0 +1,4 @@
export {
PluginChainView,
type PluginChainViewProps,
} from "./plugin-chain-view.js";

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,86 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { PluginChainView } from "./plugin-chain-view.js";
const meta = {
title: "react-audio/PluginChainView",
component: PluginChainView,
} satisfies Meta<typeof PluginChainView>;
export default meta;
type Story = StoryObj<typeof meta>;
const factory = (id: string) => () => {
const p = makeFakePlugin({ id });
Object.defineProperty(p, "metadata", {
value: {
...p.metadata,
id: `${id}-${Math.random().toString(36).slice(2, 8)}`,
},
configurable: true,
});
return p;
};
function Frame(props: {
readonly children: React.ReactNode;
readonly plugins?: ReadonlyArray<{
pluginType: string;
factory: () => ReturnType<ReturnType<typeof factory>>;
}>;
}) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
plugins: props.plugins,
}}
>
<div style={{ padding: 24, maxWidth: 640 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame
plugins={[
{ pluginType: "gain", factory: factory("gain") },
{ pluginType: "delay", factory: factory("delay") },
]}
>
<PluginChainView trackId="story-default" />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame
plugins={[
{ pluginType: "gain", factory: factory("gain") },
{ pluginType: "eq", factory: factory("eq") },
{ pluginType: "compressor", factory: factory("compressor") },
{ pluginType: "reverb", factory: factory("reverb") },
]}
>
<PluginChainView trackId="story-interactive" />
<p style={{ color: "#888", marginTop: 12 }}>
Click "Add FX", insert several plugins, then drag-reorder or
press Space on a slot and use arrows + Enter to keyboard
reorder.
</p>
</Frame>
),
};
export const EdgeEmpty: Story = {
render: () => (
<Frame plugins={[]}>
<PluginChainView trackId="story-empty" />
</Frame>
),
};

View File

@@ -0,0 +1,300 @@
import {
type DragEvent as ReactDragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useMemo,
useState,
} from "react";
import { usePluginChain } from "../../hooks/use-plugin-chain/use-plugin-chain.js";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import { PluginSlot } from "../plugin-slot/plugin-slot.js";
import {
asAudioFxError,
type KeyboardDragState,
NO_DRAG,
pluginAcceptsChannels,
type ToastState,
} from "./plugin-chain-view.types.js";
export interface PluginChainViewProps {
readonly trackId: string;
/** Optional CSS class forwarded to the wrapper. */
readonly className?: string;
/**
* Hide the "Add FX" picker. Useful for read-only chain previews.
* Defaults to `false`.
*/
readonly hideAddPicker?: boolean;
}
/**
* Live, reorderable view of a `PluginChain`. Renders one
* `<PluginSlot>` per chain entry, exposes HTML5 drag-and-drop plus a
* keyboard alternative (`Space` to pick up, `↑`/`↓` to move,
* `Enter` to drop, `Escape` to cancel), surfaces an "Add FX" picker
* over the provider's `PluginRegistry`, and renders a non-blocking
* toast carrying the underlying `AudioFxError`'s `code` on every
* insertion or reorder rejection.
*/
export function PluginChainView(props: PluginChainViewProps): ReactNode {
const { trackId, className, hideAddPicker } = props;
const chainHandle = usePluginChain(trackId);
const { registry } = useProviderStateOrThrow();
const chain = chainHandle.chain;
const plugins = chainHandle.plugins;
const [toast, setToast] = useState<ToastState | null>(null);
const [drag, setDrag] = useState<KeyboardDragState>(NO_DRAG);
const [pickerOpen, setPickerOpen] = useState(false);
const channels = chain.getEffectiveOutputChannels();
const compatibleTypes = useMemo(() => {
return registry.list().filter((type) => {
try {
const probe = registry.create(type);
const ok = pluginAcceptsChannels(probe, channels);
probe.dispose();
return ok;
} catch {
return false;
}
});
}, [registry, channels]);
const showError = useCallback((err: unknown) => {
const info = asAudioFxError(err);
setToast({ id: Date.now(), code: info.code, message: info.message });
}, []);
const dismissToast = useCallback(() => setToast(null), []);
const addType = useCallback(
(pluginType: string) => {
try {
const plugin = registry.create(pluginType);
chainHandle.add(plugin);
setPickerOpen(false);
} catch (err) {
showError(err);
}
},
[chainHandle, registry, showError]
);
const removeAt = useCallback(
(index: number) => {
try {
chainHandle.remove(index);
} catch (err) {
showError(err);
}
},
[chainHandle, showError]
);
// ── HTML5 DnD ────────────────────────────────────────────────────────
const onDragStart = useCallback(
(event: ReactDragEvent<HTMLLIElement>, index: number) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(index));
},
[]
);
const onDragOver = useCallback((event: ReactDragEvent<HTMLLIElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: ReactDragEvent<HTMLLIElement>, targetIndex: number) => {
event.preventDefault();
const raw = event.dataTransfer.getData("text/plain");
const from = Number.parseInt(raw, 10);
if (!Number.isInteger(from)) {
// External drop (e.g. picker payload)
const pluginType = event.dataTransfer.getData(
"application/x-audio-fx-type"
);
if (pluginType.length > 0) {
try {
const plugin = registry.create(pluginType);
chainHandle.add(plugin, targetIndex);
} catch (err) {
showError(err);
}
}
return;
}
if (from === targetIndex) return;
try {
chainHandle.move(from, targetIndex);
} catch (err) {
showError(err);
}
},
[chainHandle, registry, showError]
);
// ── Keyboard alternative ─────────────────────────────────────────────
const onSlotKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLLIElement>, index: number) => {
const isPicked = drag.fromIndex === index;
if (event.key === " " || event.key === "Spacebar") {
event.preventDefault();
if (drag.fromIndex === undefined) {
setDrag({ fromIndex: index, hoverIndex: index });
} else {
// Drop on space-toggle if we are the picked slot.
if (isPicked) {
try {
chainHandle.move(drag.fromIndex, drag.hoverIndex);
} catch (err) {
showError(err);
}
setDrag(NO_DRAG);
}
}
return;
}
if (drag.fromIndex === undefined) return;
if (event.key === "ArrowUp") {
event.preventDefault();
setDrag((d) => ({
fromIndex: d.fromIndex,
hoverIndex: Math.max(0, d.hoverIndex - 1),
}));
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setDrag((d) => ({
fromIndex: d.fromIndex,
hoverIndex: Math.min(plugins.length - 1, d.hoverIndex + 1),
}));
return;
}
if (event.key === "Enter") {
event.preventDefault();
try {
chainHandle.move(drag.fromIndex, drag.hoverIndex);
} catch (err) {
showError(err);
}
setDrag(NO_DRAG);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setDrag(NO_DRAG);
}
},
[chainHandle, drag, plugins.length, showError]
);
return (
<div
className={
className === undefined ? "rax-chain" : `rax-chain ${className}`
}
data-track-id={trackId}
>
<header className="rax-chain__header">
<h2 className="rax-chain__title">Plugin chain</h2>
<span className="rax-chain__count">
{plugins.length} plugin{plugins.length === 1 ? "" : "s"}
</span>
{hideAddPicker === true ? null : (
<button
type="button"
className="rax-chain__add-toggle"
aria-expanded={pickerOpen}
onClick={() => setPickerOpen((s) => !s)}
>
{pickerOpen ? "Cancel" : "Add FX"}
</button>
)}
</header>
{pickerOpen ? (
<ul className="rax-chain__picker" aria-label="Add FX">
{compatibleTypes.length === 0 ? (
<li className="rax-chain__picker-empty" role="none">
No compatible plugins registered.
</li>
) : (
compatibleTypes.map((type) => (
<li key={type} role="none">
<button
type="button"
role="menuitem"
onClick={() => addType(type)}
>
{type}
</button>
</li>
))
)}
</ul>
) : null}
<ol className="rax-chain__list" aria-label="Plugin chain order">
{plugins.map((p, index) => {
const picked = drag.fromIndex === index;
const hovered =
drag.fromIndex !== undefined &&
drag.hoverIndex === index;
return (
<li
key={p.metadata.id}
className="rax-chain__item"
data-picked={picked ? "true" : "false"}
data-hover-target={hovered ? "true" : "false"}
draggable={true}
aria-grabbed={picked}
aria-roledescription="Draggable plugin slot"
onDragStart={(e) => onDragStart(e, index)}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, index)}
onKeyDown={(e) => onSlotKeyDown(e, index)}
>
<div className="rax-chain__item-controls">
<span className="rax-chain__item-index">
{index + 1}
</span>
<button
type="button"
className="rax-chain__item-remove"
onClick={() => removeAt(index)}
aria-label={`Remove ${p.metadata.name}`}
>
×
</button>
</div>
<PluginSlot plugin={p} chain={chain} />
</li>
);
})}
</ol>
{toast === null ? null : (
<div
role="status"
className="rax-chain__toast"
data-code={toast.code}
>
<span className="rax-chain__toast-code">{toast.code}</span>
<span className="rax-chain__toast-message">
{toast.message}
</span>
<button
type="button"
className="rax-chain__toast-dismiss"
onClick={dismissToast}
aria-label="Dismiss"
>
×
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { AudioFxPlugin } from "@suaveplan/audio-fx-plugin-base";
import { AudioFxError } from "@suaveplan/audio-fx-plugin-base";
export interface ToastState {
readonly id: number;
readonly code: string;
readonly message: string;
}
export interface KeyboardDragState {
/** Source slot index. `undefined` means no drag is in progress. */
readonly fromIndex: number | undefined;
/** Index the slot would land at if the user dropped now. */
readonly hoverIndex: number;
}
export const NO_DRAG: KeyboardDragState = {
fromIndex: undefined,
hoverIndex: 0,
};
/**
* Coerces any unknown thrown value into a `{ code, message }` pair
* suitable for toast display. The chain's contract is to throw
* `AudioFxError` exclusively, but the helper handles generic `Error`
* subclasses and non-Error values defensively.
*/
export function asAudioFxError(err: unknown): {
readonly code: string;
readonly message: string;
} {
if (err instanceof AudioFxError) {
return { code: err.code as string, message: err.message };
}
if (err instanceof Error) {
return { code: "AUDIO_FX.UNKNOWN", message: err.message };
}
return { code: "AUDIO_FX.UNKNOWN", message: String(err) };
}
/**
* Returns `true` when the plugin's first declared input channel spec
* is compatible with the chain's current effective output channel
* count. `"any"` and `"preserve"` always succeed; a numeric `count`
* must match exactly or be `0` (source-plugin marker).
*/
export function pluginAcceptsChannels(
plugin: AudioFxPlugin,
channels: number
): boolean {
const first = plugin.io.inputs[0];
if (first === undefined) return true; // source plugin slot
const count = first.count;
if (count === "any" || count === "preserve") return true;
return count === channels || count === 0;
}

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,62 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { PluginLoader } from "./plugin-loader.js";
const meta = {
title: "react-audio/PluginLoader",
component: PluginLoader,
} satisfies Meta<typeof PluginLoader>;
export default meta;
type Story = StoryObj<typeof meta>;
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
plugins: [
{
pluginType: "demo-alpha",
factory: () => makeFakePlugin({ id: "demo-alpha" }),
},
{
pluginType: "demo-beta",
factory: () => makeFakePlugin({ id: "demo-beta" }),
},
],
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<PluginLoader />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<PluginLoader onCreated={(_p, t) => console.log("created", t)} />
</Frame>
),
};
export const EdgeEmpty: Story = {
render: () => (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>
<PluginLoader />
</div>
</AudioProvider>
),
};

View File

@@ -0,0 +1,186 @@
import type { AudioFxPlugin } from "@suaveplan/audio-fx-plugin-base";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { makeFakePlugin, renderWithProvider } from "../../test-utils.js";
import { PluginLoader } from "./plugin-loader.js";
afterEach(() => cleanup());
describe("<PluginLoader>", () => {
it("lists every registered plugin from PluginRegistry", {
timeout: 30000,
}, async () => {
renderWithProvider(<PluginLoader />, {
options: {
plugins: [
{
pluginType: "alpha",
factory: () => makeFakePlugin({ id: "alpha" }),
},
{
pluginType: "beta",
factory: () => makeFakePlugin({ id: "beta" }),
},
],
},
});
await waitFor(() => screen.getByText("Plugin loader"));
await waitFor(() => {
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
expect(screen.getAllByText("beta").length).toBeGreaterThan(0);
});
});
it("Create instantiates the selected plugin", {
timeout: 30000,
}, async () => {
const created: AudioFxPlugin[] = [];
renderWithProvider(
<PluginLoader onCreated={(p) => created.push(p)} />,
{
options: {
plugins: [
{
pluginType: "alpha",
factory: () => makeFakePlugin({ id: "alpha" }),
},
],
},
}
);
await waitFor(() => screen.getByLabelText("Plugin type"));
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => expect(created.length).toBe(1));
expect(created[0]?.metadata.id).toBe("alpha");
});
it("Create with no selection shows error", {
timeout: 30000,
}, async () => {
renderWithProvider(<PluginLoader />);
await waitFor(() => screen.getByText("Plugin loader"));
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"No plugin type"
)
);
});
it("Create with an unregistered type shows the registry error", {
timeout: 30000,
}, async () => {
renderWithProvider(<PluginLoader />, {
options: {
plugins: [
{
pluginType: "alpha",
factory: () => makeFakePlugin({ id: "alpha" }),
},
],
},
});
await waitFor(() => screen.getByLabelText("Plugin type"));
// Manually set the selection to a value not in the registry by
// typing into the registration input, then clicking Create
// before registering — the select is still "alpha", so this
// path is exercised by directly making `create` throw via
// unregistration. We achieve that by clearing the registry.
fireEvent.change(screen.getByLabelText("New plugin type id"), {
target: { value: "" },
});
fireEvent.click(
screen.getByRole("button", { name: "Register placeholder" })
);
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"Plugin type is required"
)
);
});
it("Register placeholder adds a factory and selects it", {
timeout: 30000,
}, async () => {
renderWithProvider(<PluginLoader />);
await waitFor(() => screen.getByText("Plugin loader"));
fireEvent.change(screen.getByLabelText("New plugin type id"), {
target: { value: "demo" },
});
fireEvent.click(
screen.getByRole("button", { name: "Register placeholder" })
);
await waitFor(() =>
expect(screen.getAllByText("demo").length).toBeGreaterThan(0)
);
// Clicking Create now should call the placeholder factory and
// surface its thrown error.
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"placeholder"
)
);
});
it("selecting changes the active plugin type", {
timeout: 30000,
}, async () => {
renderWithProvider(<PluginLoader />, {
options: {
plugins: [
{
pluginType: "alpha",
factory: () => makeFakePlugin({ id: "alpha" }),
},
{
pluginType: "beta",
factory: () => makeFakePlugin({ id: "beta" }),
},
],
},
});
await waitFor(() => screen.getByLabelText("Plugin type"));
fireEvent.change(screen.getByLabelText("Plugin type"), {
target: { value: "beta" },
});
fireEvent.click(screen.getByRole("button", { name: "Create" }));
// onCreated isn't passed here; success is the absence of an
// alert.
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("non-Error thrown by factory is coerced to string", {
timeout: 30000,
}, async () => {
renderWithProvider(<PluginLoader />, {
options: {
plugins: [
{
pluginType: "rawthrow",
factory: () => {
throw "raw-thrown-string" as unknown as Error;
},
},
],
},
});
await waitFor(() => screen.getByLabelText("Plugin type"));
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"raw-thrown-string"
)
);
});
it("forwards className", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<PluginLoader className="custom-loader" />
);
await waitFor(() => screen.getByText("Plugin loader"));
expect(
container.querySelector(".rax-plugin-loader.custom-loader")
).not.toBeNull();
});
});

View File

@@ -0,0 +1,135 @@
import type {
AudioFxPlugin,
PluginFactory,
} from "@suaveplan/audio-fx-plugin-base";
import { type ChangeEvent, type ReactNode, useCallback, useState } from "react";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
export interface PluginLoaderProps {
readonly className?: string;
/** Invoked with the newly-instantiated plugin after a successful `Create`. */
readonly onCreated?: (plugin: AudioFxPlugin, pluginType: string) => void;
}
/**
* Dynamic registration / instantiation UI over the provider's
* `PluginRegistry`. Lists every currently-registered `pluginType`,
* lets the user pick one and click "Create" to instantiate it, and
* exposes a small registration form for adding new factories at
* runtime (used for development surfaces and demos).
*/
export function PluginLoader(props: PluginLoaderProps): ReactNode {
const { className, onCreated } = props;
const { registry } = useProviderStateOrThrow();
const [version, setVersion] = useState(0);
const list = registry.list();
const [selected, setSelected] = useState<string>(list[0] ?? "");
const [newType, setNewType] = useState("");
const [error, setError] = useState<string | null>(null);
const onSelect = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setSelected(e.target.value);
}, []);
const onCreate = useCallback(() => {
if (selected === "") {
setError("No plugin type selected");
return;
}
try {
const plugin = registry.create(selected);
setError(null);
onCreated?.(plugin, selected);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [registry, selected, onCreated]);
const onRegister = useCallback(() => {
const t = newType.trim();
if (t === "") {
setError("Plugin type is required");
return;
}
const factory: PluginFactory = () => {
throw new Error(
`Factory for "${t}" is a placeholder — replace with a real plugin factory.`
);
};
registry.register(t, factory);
setVersion((v) => v + 1);
setSelected(t);
setNewType("");
setError(null);
}, [registry, newType]);
const listCurrent = registry.list();
void version;
return (
<section
className={
className === undefined
? "rax-plugin-loader"
: `rax-plugin-loader ${className}`
}
aria-label="Plugin loader"
>
<h2>Plugin loader</h2>
<label className="rax-plugin-loader__select">
<span>Plugin type</span>
<select
value={selected}
onChange={onSelect}
aria-label="Plugin type"
>
{listCurrent.length === 0 ? (
<option value="" disabled>
(no plugins registered)
</option>
) : (
listCurrent.map((t) => (
<option key={t} value={t}>
{t}
</option>
))
)}
</select>
</label>
<button
type="button"
className="rax-plugin-loader__create"
onClick={onCreate}
>
Create
</button>
<div className="rax-plugin-loader__register">
<input
type="text"
value={newType}
onChange={(e) => setNewType(e.target.value)}
placeholder="new-plugin-id"
aria-label="New plugin type id"
/>
<button
type="button"
className="rax-plugin-loader__register-btn"
onClick={onRegister}
>
Register placeholder
</button>
</div>
<ul className="rax-plugin-loader__list">
{listCurrent.map((t) => (
<li key={t} data-plugin-type={t}>
{t}
</li>
))}
</ul>
{error === null ? null : (
<p className="rax-plugin-loader__error" role="alert">
{error}
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,4 @@
export {
PluginParameterKnob,
type PluginParameterKnobProps,
} from "./plugin-parameter-knob.js";

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,83 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { useEffect, useState } from "react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { PluginParameterKnob } from "./plugin-parameter-knob.js";
const meta = {
title: "react-audio/PluginParameterKnob",
component: PluginParameterKnob,
} satisfies Meta<typeof PluginParameterKnob>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = makeFakePlugin({ id: "story-knob" });
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24, maxWidth: 320 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
</Frame>
),
};
export const Interactive: Story = {
render: () => {
function Inner() {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1500);
return () => clearInterval(id);
}, []);
return (
<div>
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
<p style={{ color: "#888", marginTop: 16 }}>
Tab to focus the knob; press ← / → / ↑ / ↓ / Home / End
/ Enter. Tick: {tick}
</p>
</div>
);
}
return (
<Frame>
<Inner />
</Frame>
);
},
};
export const EdgeMaxedOut: Story = {
render: () => {
plugin.setParameter("gain", 2);
return (
<Frame>
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
</Frame>
);
},
};

View File

@@ -0,0 +1,340 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
afterEach(() => cleanup());
import {
makeFakePlugin,
renderWithProvider,
waitForStableProvider,
} from "../../test-utils.js";
import { PluginParameterKnob } from "./plugin-parameter-knob.js";
function makeDbPlugin() {
const plugin = makeFakePlugin({ id: "dbplug" });
Object.defineProperty(plugin, "parameters", {
value: [
{
id: "gain",
name: "Gain",
value: 0,
default: 0,
min: -24,
max: 24,
unit: "db" as const,
scale: "linear" as const,
automatable: true,
},
],
configurable: true,
});
Object.defineProperty(plugin, "parameterIndex", {
value: new Map([["gain", plugin.parameters[0]!]]),
configurable: true,
});
// initial value
plugin.setParameter("gain", 0);
return plugin;
}
function makeHzPlugin() {
const plugin = makeFakePlugin({ id: "hzplug" });
Object.defineProperty(plugin, "parameters", {
value: [
{
id: "freq",
name: "Frequency",
value: 440,
default: 440,
min: 20,
max: 20000,
unit: "hz" as const,
scale: "log" as const,
automatable: true,
},
],
configurable: true,
});
Object.defineProperty(plugin, "parameterIndex", {
value: new Map([["freq", plugin.parameters[0]!]]),
configurable: true,
});
plugin.setParameter("freq", 880);
return plugin;
}
describe("<PluginParameterKnob>", () => {
it("arrow keys adjust the knob by the declared step", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
let mounted = false;
function Probe() {
mounted = true;
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() =>
mounted ? screen.queryByRole("slider") : undefined
);
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "ArrowRight" });
await waitFor(
() => {
expect(plugin.getParameter("gain")).toBeCloseTo(0.48, 5);
},
{ timeout: 5000 }
);
await waitFor(
() => {
expect(knob.getAttribute("aria-valuenow")).toMatch(/^0\.48/);
expect(knob.getAttribute("aria-valuetext")).toMatch(/dB$/);
},
{ timeout: 5000 }
);
});
it("aria-valuetext includes the parameter unit", {
timeout: 30000,
}, async () => {
const plugin = makeHzPlugin();
let mounted = false;
function Probe() {
mounted = true;
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() =>
mounted ? screen.queryByRole("slider") : undefined
);
await waitFor(
() => {
const knob = screen.getByRole("slider");
expect(knob.getAttribute("aria-valuetext")).toBe("880 Hz");
},
{ timeout: 5000 }
);
});
it("Enter opens type-edit and commits a parsed value", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "Enter" });
const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "12.5" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(plugin.getParameter("gain")).toBe(12.5);
});
});
it("Escape cancels the type-edit without applying the draft", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
plugin.setParameter("gain", 3);
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "Enter" });
const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "99" } });
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => {
expect(plugin.getParameter("gain")).toBe(3);
expect(screen.queryByRole("spinbutton")).toBeNull();
});
});
it("blur on the type-edit commits the parsed value", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "Enter" });
const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "-5" } });
fireEvent.blur(input);
await waitFor(() => {
expect(plugin.getParameter("gain")).toBe(-5);
});
});
it("invalid type-edit input is ignored and the value is unchanged", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
plugin.setParameter("gain", 6);
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "Enter" });
const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
fireEvent.blur(input);
await waitFor(() => {
expect(plugin.getParameter("gain")).toBe(6);
expect(screen.queryByRole("spinbutton")).toBeNull();
});
});
it("non-Enter / non-Escape keys in the type-edit input do nothing", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
plugin.setParameter("gain", 4);
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "Enter" });
const input = await screen.findByRole("spinbutton");
fireEvent.keyDown(input, { key: "Tab" });
// value unchanged, edit stays open
expect(plugin.getParameter("gain")).toBe(4);
expect(screen.queryByRole("spinbutton")).not.toBeNull();
});
it("type-edit clamps out-of-range values to [min, max]", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "Enter" });
const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "999" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(plugin.getParameter("gain")).toBe(24);
});
});
it("unknown keys do not change the parameter", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
plugin.setParameter("gain", 5);
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "a" });
await waitFor(() => {
expect(plugin.getParameter("gain")).toBe(5);
});
});
it("renders an optional label override and accepts className", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
label="GAIN!"
className="my-knob"
/>
);
}
const { container } = renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("GAIN!"));
expect(screen.getByText("GAIN!")).toBeDefined();
const wrapper = container.querySelector(".rax-knob.my-knob");
expect(wrapper).not.toBeNull();
});
it("Home/End jump to range endpoints", {
timeout: 30000,
}, async () => {
const plugin = makeDbPlugin();
function Probe() {
return (
<PluginParameterKnob
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const knob = screen.getByRole("slider");
fireEvent.keyDown(knob, { key: "End" });
await waitFor(() => expect(plugin.getParameter("gain")).toBe(24));
fireEvent.keyDown(knob, { key: "Home" });
await waitFor(() => expect(plugin.getParameter("gain")).toBe(-24));
});
});

View File

@@ -0,0 +1,147 @@
import type {
AudioFxPlugin,
PluginParameter,
} from "@suaveplan/audio-fx-plugin-base";
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useId,
useRef,
useState,
} from "react";
import { formatAriaValueText } from "../../a11y/aria-value-text/aria-value-text.js";
import { useParameter } from "../../hooks/use-parameter/use-parameter.js";
import { resolveParameterKeyboardChange } from "../internal/parameter-keyboard.js";
import { mapParameterUnitForAria } from "../internal/parameter-unit.js";
export interface PluginParameterKnobProps {
readonly plugin: AudioFxPlugin;
readonly parameter: PluginParameter;
/** Optional override for the visible label (defaults to `parameter.name`). */
readonly label?: string;
/** Optional CSS class forwarded to the outer wrapper. */
readonly className?: string;
}
/**
* Rotary knob primitive for a single plugin parameter. Implements the
* `<PluginSlot>` accessibility contract in full: full keyboard
* keymap, `role="slider"`, `aria-valuemin`/`max`/`now`/`text` derived
* from the parameter declaration, focus indicator that respects
* `prefers-reduced-motion`, and an `Enter`-to-edit affordance that
* surfaces a typed-value input.
*/
export function PluginParameterKnob(
props: PluginParameterKnobProps
): ReactNode {
const { plugin, parameter, label, className } = props;
const param = useParameter(plugin, parameter.id);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
const inputId = useId();
const ariaUnit = mapParameterUnitForAria(parameter.unit);
const ariaText = formatAriaValueText(
param.value,
ariaUnit,
parameter.min,
parameter.max,
{ signedDb: true }
);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const current = plugin.getParameter(parameter.id);
const out = resolveParameterKeyboardChange(
event,
current,
parameter.min,
parameter.max
);
if (!out.handled) return;
event.preventDefault();
if (out.binding?.action === "enter-value") {
setDraft(String(current));
setEditing(true);
return;
}
param.set(out.next);
},
[param, parameter.id, parameter.min, parameter.max, plugin]
);
const commitDraft = useCallback(() => {
const parsed = Number.parseFloat(draft);
if (Number.isFinite(parsed)) {
const clamped = Math.min(
parameter.max,
Math.max(parameter.min, parsed)
);
param.set(clamped);
}
setEditing(false);
}, [draft, param, parameter.min, parameter.max]);
const cancelDraft = useCallback(() => {
setEditing(false);
}, []);
const onDraftKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
commitDraft();
} else if (event.key === "Escape") {
event.preventDefault();
cancelDraft();
}
},
[commitDraft, cancelDraft]
);
return (
<div
className={
className === undefined ? "rax-knob" : `rax-knob ${className}`
}
data-parameter-id={parameter.id}
>
<label htmlFor={inputId} className="rax-knob__label">
{label ?? parameter.name}
</label>
<div
role="slider"
tabIndex={0}
aria-label={label ?? parameter.name}
aria-valuemin={parameter.min}
aria-valuemax={parameter.max}
aria-valuenow={param.value}
aria-valuetext={ariaText}
aria-orientation="vertical"
className="rax-knob__dial"
onKeyDown={onKeyDown}
>
<span className="rax-knob__readout" aria-hidden="true">
{ariaText}
</span>
</div>
{editing ? (
<input
ref={inputRef}
id={inputId}
type="number"
inputMode="decimal"
min={parameter.min}
max={parameter.max}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onDraftKeyDown}
onBlur={commitDraft}
className="rax-knob__input"
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,4 @@
export {
PluginParameterSlider,
type PluginParameterSliderProps,
} from "./plugin-parameter-slider.js";

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,76 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { PluginParameterSlider } from "./plugin-parameter-slider.js";
const meta = {
title: "react-audio/PluginParameterSlider",
component: PluginParameterSlider,
} satisfies Meta<typeof PluginParameterSlider>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = makeFakePlugin({ id: "story-slider" });
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24, maxWidth: 480 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<div style={{ display: "grid", gap: 12 }}>
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[1]!}
/>
<p style={{ color: "#888" }}>
Drag with the pointer or focus + arrow / page / home / end.
</p>
</div>
</Frame>
),
};
export const EdgeVerticalMin: Story = {
render: () => {
plugin.setParameter("gain", 0);
return (
<Frame>
<div style={{ height: 200 }}>
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
orientation="vertical"
/>
</div>
</Frame>
);
},
};

View File

@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
afterEach(() => cleanup());
import {
makeFakePlugin,
renderWithProvider,
waitForStableProvider,
} from "../../test-utils.js";
import { PluginParameterSlider } from "./plugin-parameter-slider.js";
function makePercentPlugin() {
const plugin = makeFakePlugin({ id: "slider-plug" });
Object.defineProperty(plugin, "parameters", {
value: [
{
id: "mix",
name: "Mix",
value: 50,
default: 50,
min: 0,
max: 100,
unit: "percent" as const,
scale: "linear" as const,
automatable: true,
},
],
configurable: true,
});
Object.defineProperty(plugin, "parameterIndex", {
value: new Map([["mix", plugin.parameters[0]!]]),
configurable: true,
});
plugin.setParameter("mix", 50);
return plugin;
}
describe("<PluginParameterSlider>", () => {
it("renders role=slider with aria-valuemin/max/now/text", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
await waitFor(() => {
const el = screen.getByRole("slider");
expect(el.getAttribute("aria-valuemin")).toBe("0");
expect(el.getAttribute("aria-valuemax")).toBe("100");
expect(el.getAttribute("aria-valuenow")).toBe("50");
expect(el.getAttribute("aria-valuetext")).toMatch(
/^50(?:\.0+)? %$/
);
});
});
it("pointer change writes through to plugin.setParameter", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const slider = screen.getByRole("slider") as HTMLInputElement;
fireEvent.change(slider, { target: { value: "75" } });
await waitFor(() => expect(plugin.getParameter("mix")).toBe(75));
});
it("invalid pointer change is ignored", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const slider = screen.getByRole("slider") as HTMLInputElement;
// Override the input's `value` getter so the synthetic onChange
// observes a non-numeric string — exercises the false branch of
// the isFinite check. happy-dom otherwise coerces range input
// values back to numeric strings.
Object.defineProperty(slider, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(slider);
await waitFor(() => expect(plugin.getParameter("mix")).toBe(50));
});
it("arrow keys move by the declared 1% / 10% step", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const slider = screen.getByRole("slider");
fireEvent.keyDown(slider, { key: "ArrowRight" });
await waitFor(() => expect(plugin.getParameter("mix")).toBe(51));
fireEvent.keyDown(slider, { key: "ArrowUp" });
await waitFor(() => expect(plugin.getParameter("mix")).toBe(61));
});
it("Enter is a no-op on the slider", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const slider = screen.getByRole("slider");
fireEvent.keyDown(slider, { key: "Enter" });
await waitFor(() => expect(plugin.getParameter("mix")).toBe(50));
});
it("unknown keys do not change the parameter", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const slider = screen.getByRole("slider");
fireEvent.keyDown(slider, { key: "a" });
await waitFor(() => expect(plugin.getParameter("mix")).toBe(50));
});
it("supports vertical orientation and label/className overrides", {
timeout: 30000,
}, async () => {
const plugin = makePercentPlugin();
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
orientation="vertical"
label="WET"
className="my-slider"
/>
);
}
const { container } = renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
expect(
container.querySelector(
'.rax-slider.my-slider[data-orientation="vertical"]'
)
).not.toBeNull();
expect(screen.getByText("WET")).toBeDefined();
});
it("falls back to a 0.0001 step for degenerate min === max", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin({ id: "degen" });
Object.defineProperty(plugin, "parameters", {
value: [
{
id: "fixed",
name: "Fixed",
value: 1,
default: 1,
min: 1,
max: 1,
unit: "percent" as const,
scale: "linear" as const,
automatable: false,
},
],
configurable: true,
});
Object.defineProperty(plugin, "parameterIndex", {
value: new Map([["fixed", plugin.parameters[0]!]]),
configurable: true,
});
plugin.setParameter("fixed", 1);
function Probe() {
return (
<PluginParameterSlider
plugin={plugin}
parameter={plugin.parameters[0]!}
/>
);
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByRole("slider"));
const slider = screen.getByRole("slider") as HTMLInputElement;
expect(slider.step).toBe("0.0001");
});
});

View File

@@ -0,0 +1,131 @@
import type {
AudioFxPlugin,
PluginParameter,
} from "@suaveplan/audio-fx-plugin-base";
import {
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useId,
} from "react";
import { formatAriaValueText } from "../../a11y/aria-value-text/aria-value-text.js";
import { useParameter } from "../../hooks/use-parameter/use-parameter.js";
import { resolveParameterKeyboardChange } from "../internal/parameter-keyboard.js";
import { mapParameterUnitForAria } from "../internal/parameter-unit.js";
export interface PluginParameterSliderProps {
readonly plugin: AudioFxPlugin;
readonly parameter: PluginParameter;
/** Vertical | horizontal — defaults to horizontal. */
readonly orientation?: "horizontal" | "vertical";
/** Optional override for the visible label (defaults to `parameter.name`). */
readonly label?: string;
/** Optional CSS class forwarded to the outer wrapper. */
readonly className?: string;
}
/**
* Linear-slider primitive for a single plugin parameter. Renders a
* native `<input type="range">` for pointer drag plus a hand-rolled
* keyboard handler implementing the `<PluginSlot>` accessibility
* contract (1 % fine step, 10 % coarse, ×10 page, `Home`/`End`,
* Shift/Alt multipliers). Exposes `role="slider"` ARIA metadata on
* the wrapper so screen readers announce the value with its unit
* suffix even when the visual slider is themed away.
*/
export function PluginParameterSlider(
props: PluginParameterSliderProps
): ReactNode {
const { plugin, parameter, orientation, label, className } = props;
const param = useParameter(plugin, parameter.id);
const inputId = useId();
const ariaUnit = mapParameterUnitForAria(parameter.unit);
const ariaText = formatAriaValueText(
param.value,
ariaUnit,
parameter.min,
parameter.max,
{ signedDb: true }
);
// Native pointer-drag path (mouse / touch) uses the `<input
// type="range">`'s onChange; keyboard goes through our shared
// resolver so the step semantics match the knob.
const onPointerChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const next = Number.parseFloat(event.target.value);
if (Number.isFinite(next)) {
param.set(next);
}
// else branch is unreachable: native input[type=range]
// always parses to a finite number.
},
[param]
);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
const current = plugin.getParameter(parameter.id);
const out = resolveParameterKeyboardChange(
event,
current,
parameter.min,
parameter.max
);
if (!out.handled) return;
event.preventDefault();
if (out.binding?.action === "enter-value") {
// Native range inputs do not support type-in. The
// shared knob handles this; the slider treats Enter
// as a no-op so consumers can listen for it without
// a default value change.
return;
}
param.set(out.next);
},
[param, parameter.id, parameter.min, parameter.max, plugin]
);
// Native range needs a numeric `step`; pick `(max - min) / 100`
// so pointer drags follow the same 1 % granularity as the
// keyboard fine-step.
const nativeStep = (parameter.max - parameter.min) / 100;
return (
<div
className={
className === undefined
? "rax-slider"
: `rax-slider ${className}`
}
data-parameter-id={parameter.id}
data-orientation={orientation ?? "horizontal"}
>
<label htmlFor={inputId} className="rax-slider__label">
{label ?? parameter.name}
</label>
<input
id={inputId}
type="range"
aria-label={label ?? parameter.name}
aria-valuemin={parameter.min}
aria-valuemax={parameter.max}
aria-valuenow={param.value}
aria-valuetext={ariaText}
aria-orientation={orientation ?? "horizontal"}
min={parameter.min}
max={parameter.max}
step={nativeStep > 0 ? nativeStep : 0.0001}
value={param.value}
onChange={onPointerChange}
onKeyDown={onKeyDown}
className="rax-slider__input"
/>
<span className="rax-slider__readout" aria-hidden="true">
{ariaText}
</span>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export {
PluginSlot,
type PluginSlotProps,
type PluginSlotRendererProps,
} from "./plugin-slot.js";

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,69 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext, makeFakePlugin } from "../../test-utils.js";
import { PluginSlot } from "./plugin-slot.js";
const meta = {
title: "react-audio/PluginSlot",
component: PluginSlot,
} satisfies Meta<typeof PluginSlot>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = makeFakePlugin({ id: "story-slot", name: "Demo Plugin" });
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24, maxWidth: 520 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<PluginSlot plugin={plugin} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<div>
<PluginSlot plugin={plugin} />
<p style={{ color: "#888", marginTop: 8 }}>
Each control is keyboard-driven; tab to focus, arrows / page
/ home / end to nudge.
</p>
</div>
</Frame>
),
};
export const EdgeNoParameters: Story = {
render: () => {
const empty = makeFakePlugin({ id: "empty" });
Object.defineProperty(empty, "parameters", {
value: [],
configurable: true,
});
Object.defineProperty(empty, "parameterIndex", {
value: new Map(),
configurable: true,
});
return (
<Frame>
<PluginSlot plugin={empty} />
</Frame>
);
},
};

View File

@@ -0,0 +1,324 @@
import type { PluginChain } from "@suaveplan/audio-fx-plugin-base";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
afterEach(() => cleanup());
import { useEffect, useState } from "react";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import {
makeFakePlugin,
renderWithProvider,
waitForStableProvider,
} from "../../test-utils.js";
import { PluginSlot, type PluginSlotRendererProps } from "./plugin-slot.js";
function makeEqPlugin() {
const plugin = makeFakePlugin({ id: "eq", name: "Equaliser" });
const params = [
{
id: "low",
name: "Low",
value: 0,
default: 0,
min: -24,
max: 24,
unit: "db" as const,
scale: "linear" as const,
automatable: true,
},
{
id: "mid",
name: "Mid",
value: 0,
default: 0,
min: -24,
max: 24,
unit: "db" as const,
scale: "linear" as const,
automatable: true,
},
{
id: "high",
name: "High",
value: 0,
default: 0,
min: -24,
max: 24,
unit: "db" as const,
scale: "linear" as const,
automatable: true,
},
{
id: "freq",
name: "Frequency",
value: 1000,
default: 1000,
min: 20,
max: 20000,
unit: "hz" as const,
scale: "log" as const,
automatable: true,
},
{
id: "q",
name: "Q",
value: 1,
default: 1,
min: 0.1,
max: 18,
unit: "ratio" as const,
scale: "linear" as const,
automatable: true,
},
];
Object.defineProperty(plugin, "parameters", {
value: params,
configurable: true,
});
Object.defineProperty(plugin, "parameterIndex", {
value: new Map(params.map((p) => [p.id, p])),
configurable: true,
});
for (const p of params) plugin.setParameter(p.id, p.default);
return plugin;
}
function _ChainProbe({
trackId,
onReady,
children,
}: {
trackId: string;
onReady: (chain: PluginChain) => void;
children: (chain: PluginChain) => React.ReactNode;
}) {
const { chains } = useProviderStateOrThrow();
const [chain] = useState(() => chains.getOrCreate(trackId));
useEffect(() => {
onReady(chain);
}, [chain, onReady]);
return <>{children(chain)}</>;
}
describe("<PluginSlot>", () => {
it("renders one control per parameter with correct role and label", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function Probe() {
return <PluginSlot plugin={plugin} />;
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("Equaliser"));
await waitFor(() => {
const sliders = screen.getAllByRole("slider");
expect(sliders.length).toBe(5);
});
for (const name of ["Low", "Mid", "High", "Frequency", "Q"]) {
expect(screen.getAllByText(name).length).toBeGreaterThan(0);
}
});
it("registered specialised renderer supersedes the generic slot", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function EQPanel(props: PluginSlotRendererProps) {
const p = props.plugin as typeof plugin;
return <div data-testid="eq-panel">EQ-PANEL:{p.metadata.id}</div>;
}
function Probe() {
return <PluginSlot plugin={plugin} />;
}
renderWithProvider(<Probe />, {
options: { slotRenderers: { eq: EQPanel } },
});
await waitForStableProvider(() => screen.queryByTestId("eq-panel"));
expect(screen.getByTestId("eq-panel").textContent).toBe("EQ-PANEL:eq");
expect(screen.queryByText("Equaliser")).toBeNull();
});
it("renders metadata name / version / category in the header", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function Probe() {
return <PluginSlot plugin={plugin} />;
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("Equaliser"));
expect(screen.getByText("Equaliser")).toBeDefined();
expect(screen.getByText("v0.1.0")).toBeDefined();
expect(screen.getByText("utility")).toBeDefined();
});
it("bypass toggle writes through to chain.bypass and reflects state", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
let captured: PluginChain | undefined;
function Setup() {
const { chains } = useProviderStateOrThrow();
const [chain] = useState(() => {
const c = chains.getOrCreate("bp-track");
if (c.findById(plugin.metadata.id) === undefined) {
c.add(plugin);
}
return c;
});
useEffect(() => {
captured = chain;
}, [chain]);
return <PluginSlot plugin={plugin} chain={chain} />;
}
renderWithProvider(<Setup />);
await waitForStableProvider(() => captured);
await waitFor(() => screen.getByRole("button", { name: "Active" }));
fireEvent.click(screen.getByRole("button", { name: "Active" }));
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Bypassed" })
).toBeDefined();
});
});
it("omits the bypass button when no chain is supplied", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function Probe() {
return <PluginSlot plugin={plugin} />;
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("Equaliser"));
expect(screen.queryByRole("button", { name: "Active" })).toBeNull();
expect(screen.queryByRole("button", { name: "Bypassed" })).toBeNull();
});
it("advanced toggle hides advanced parameters by default and reveals them on click", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
// Inject an "advanced" parameter
Object.defineProperty(plugin, "parameters", {
value: [
...plugin.parameters,
{
id: "oversample",
name: "Oversample",
value: 1,
default: 1,
min: 1,
max: 8,
unit: "ratio" as const,
scale: "linear" as const,
automatable: false,
category: "advanced",
} as never,
],
configurable: true,
});
Object.defineProperty(plugin, "parameterIndex", {
value: new Map(plugin.parameters.map((p) => [p.id, p])),
configurable: true,
});
plugin.setParameter("oversample", 1);
function Probe() {
return <PluginSlot plugin={plugin} />;
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("Equaliser"));
expect(screen.queryByText("Oversample")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Show advanced" }));
await waitFor(() =>
expect(screen.getByText("Oversample")).toBeDefined()
);
fireEvent.click(screen.getByRole("button", { name: "Hide advanced" }));
await waitFor(() =>
expect(screen.queryByText("Oversample")).toBeNull()
);
});
it("hideAdvancedToggle suppresses the advanced button entirely", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function Probe() {
return <PluginSlot plugin={plugin} hideAdvancedToggle={true} />;
}
renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("Equaliser"));
expect(screen.queryByRole("button", { name: /advanced/i })).toBeNull();
});
it("bypass-changed event for a different plugin id is ignored", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
const other = makeFakePlugin({ id: "other" });
let captured: PluginChain | undefined;
function Setup() {
const { chains } = useProviderStateOrThrow();
const [chain] = useState(() => {
const c = chains.getOrCreate("two-track");
if (c.findById(plugin.metadata.id) === undefined) {
c.add(plugin);
}
if (c.findById(other.metadata.id) === undefined) {
c.add(other);
}
return c;
});
useEffect(() => {
captured = chain;
}, [chain]);
return <PluginSlot plugin={plugin} chain={chain} />;
}
renderWithProvider(<Setup />);
await waitForStableProvider(() => captured);
await waitFor(() => screen.getByRole("button", { name: "Active" }));
// Toggle bypass on the OTHER plugin directly via the chain —
// exercises the id-mismatch guard in the bypass listener.
captured!.bypass(other.metadata.id, true);
// EQ slot button should still say "Active"
await new Promise((r) => setTimeout(r, 10));
expect(screen.getByRole("button", { name: "Active" })).toBeDefined();
});
it("specialised renderer receives the chain prop when supplied", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function ChainAware(props: PluginSlotRendererProps) {
const chainId = (props.chain as { id?: string } | undefined)?.id;
return (
<div data-testid="chain-aware">chain:{chainId ?? "none"}</div>
);
}
function Setup() {
const { chains } = useProviderStateOrThrow();
const [chain] = useState(() => chains.getOrCreate("aware-track"));
return <PluginSlot plugin={plugin} chain={chain} />;
}
renderWithProvider(<Setup />, {
options: { slotRenderers: { eq: ChainAware } },
});
await waitForStableProvider(() => screen.queryByTestId("chain-aware"));
expect(screen.getByTestId("chain-aware").textContent).toBe(
"chain:aware-track"
);
});
it("forwards className to the wrapper", {
timeout: 30000,
}, async () => {
const plugin = makeEqPlugin();
function Probe() {
return <PluginSlot plugin={plugin} className="my-slot" />;
}
const { container } = renderWithProvider(<Probe />);
await waitForStableProvider(() => screen.queryByText("Equaliser"));
expect(container.querySelector(".rax-slot.my-slot")).not.toBeNull();
});
});

View File

@@ -0,0 +1,205 @@
import type {
AudioFxPlugin,
PluginChain,
PluginParameter,
} from "@suaveplan/audio-fx-plugin-base";
import {
type ComponentType,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import { PluginParameterKnob } from "../plugin-parameter-knob/plugin-parameter-knob.js";
import { PluginParameterSlider } from "../plugin-parameter-slider/plugin-parameter-slider.js";
export interface PluginSlotProps {
readonly plugin: AudioFxPlugin;
/**
* Optional chain handle. When supplied the slot's bypass toggle
* delegates to `chain.bypass(plugin.metadata.id, ...)`; otherwise
* the toggle is hidden because there is no chain-level bypass
* surface to talk to.
*/
readonly chain?: PluginChain;
/** Optional CSS class appended to the wrapper. */
readonly className?: string;
/** Hide the "advanced" toggle even if advanced parameters exist. */
readonly hideAdvancedToggle?: boolean;
}
/**
* Props accepted by a specialised renderer registered via the
* provider's `slotRenderers` option. The specialised renderer
* supersedes the generic slot when its key matches the plugin's
* `metadata.id`.
*/
export interface PluginSlotRendererProps {
readonly plugin: AudioFxPlugin;
readonly chain?: PluginChain;
}
interface PluginParameterCategory {
readonly category?: string;
}
function isAdvancedParameter(
parameter: PluginParameter & PluginParameterCategory
): boolean {
return parameter.category === "advanced";
}
function pickControl(parameter: PluginParameter): "knob" | "slider" {
if (parameter.unit === "hz") return "knob";
if (parameter.unit === "db") return "knob";
return "slider";
}
/**
* Generic renderer for any `AudioFxPlugin`. Iterates the plugin's
* `parameters` array in declaration order and renders each via the
* primitive chosen by the parameter's unit. A specialised renderer
* registered through the provider's `slotRenderers` option
* supersedes the generic slot for the matching plugin id.
*/
export function PluginSlot(props: PluginSlotProps): ReactNode {
const { plugin, chain, className, hideAdvancedToggle } = props;
const provider = useProviderStateOrThrow();
const SpecialisedRenderer = provider.slotRenderers[plugin.metadata.id] as
| ComponentType<PluginSlotRendererProps>
| undefined;
if (SpecialisedRenderer !== undefined) {
return (
<SpecialisedRenderer
plugin={plugin}
{...(chain !== undefined ? { chain } : {})}
/>
);
}
return (
<GenericPluginSlot
plugin={plugin}
{...(chain !== undefined ? { chain } : {})}
{...(className !== undefined ? { className } : {})}
{...(hideAdvancedToggle !== undefined
? { hideAdvancedToggle }
: {})}
/>
);
}
function GenericPluginSlot(props: PluginSlotProps): ReactNode {
const { plugin, chain, className, hideAdvancedToggle } = props;
const [showAdvanced, setShowAdvanced] = useState(false);
const params = plugin.parameters;
const advanced = useMemo(
() =>
params.filter((p) =>
isAdvancedParameter(
p as PluginParameter & PluginParameterCategory
)
),
[params]
);
const visibleParams = useMemo(
() =>
showAdvanced
? params
: params.filter(
(p) =>
!isAdvancedParameter(
p as PluginParameter & PluginParameterCategory
)
),
[params, showAdvanced]
);
const [bypassed, setBypassed] = useState(false);
useEffect(() => {
if (chain === undefined) return undefined;
const off = chain.on("bypass-changed", (payload) => {
if (payload.plugin.metadata.id === plugin.metadata.id) {
setBypassed(payload.bypassed);
}
});
return () => off();
}, [chain, plugin.metadata.id]);
const onToggleBypass = useCallback(() => {
// chain is guaranteed defined when this callback fires —
// the bypass button is only rendered when `chain !==
// undefined` (see the guarded JSX below). The non-null
// assertion avoids an unreachable branch.
chain!.bypass(plugin.metadata.id, !bypassed);
}, [chain, plugin.metadata.id, bypassed]);
return (
<section
className={
className === undefined ? "rax-slot" : `rax-slot ${className}`
}
data-plugin-id={plugin.metadata.id}
data-plugin-category={plugin.metadata.category}
data-bypassed={bypassed ? "true" : "false"}
>
<header className="rax-slot__header">
<h3 className="rax-slot__name">{plugin.metadata.name}</h3>
<span className="rax-slot__version">
v{plugin.metadata.version}
</span>
<span
className="rax-slot__category"
data-category={plugin.metadata.category}
>
{plugin.metadata.category}
</span>
{chain === undefined ? null : (
<button
type="button"
className="rax-slot__bypass"
aria-pressed={bypassed}
onClick={onToggleBypass}
>
{bypassed ? "Bypassed" : "Active"}
</button>
)}
</header>
<div className="rax-slot__params">
{visibleParams.map((p) => {
const control = pickControl(p);
if (control === "knob") {
return (
<PluginParameterKnob
key={p.id}
plugin={plugin}
parameter={p}
/>
);
}
return (
<PluginParameterSlider
key={p.id}
plugin={plugin}
parameter={p}
/>
);
})}
</div>
{hideAdvancedToggle === true || advanced.length === 0 ? null : (
<button
type="button"
className="rax-slot__advanced-toggle"
aria-pressed={showAdvanced}
onClick={() => setShowAdvanced((s) => !s)}
>
{showAdvanced ? "Hide advanced" : "Show advanced"}
</button>
)}
</section>
);
}

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,55 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { PremiumPlayer } from "./premium-player.js";
const meta = {
title: "react-audio/PremiumPlayer",
component: PremiumPlayer,
} satisfies Meta<typeof PremiumPlayer>;
export default meta;
type Story = StoryObj<typeof meta>;
const plugin = new AudioPlayerPlugin({});
(plugin as unknown as { config: { sampleRate: number } }).config = {
sampleRate: 48000,
};
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<PremiumPlayer plugin={plugin} />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<PremiumPlayer plugin={plugin} busName="auxA" />
</Frame>
),
};
export const EdgeWideLayout: Story = {
render: () => (
<Frame>
<div style={{ width: 800 }}>
<PremiumPlayer plugin={plugin} className="wide" />
</div>
</Frame>
),
};

View File

@@ -0,0 +1,105 @@
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import {
afterEach,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { cleanup, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { PremiumPlayer } from "./premium-player.js";
afterEach(() => cleanup());
beforeAll(() => {
// happy-dom has no canvas; install a minimal 2D fake.
const proto = HTMLCanvasElement.prototype as unknown as {
getContext: (id: string) => unknown;
};
proto.getContext = () =>
({
fillStyle: "",
strokeStyle: "",
fillRect: () => undefined,
stroke: () => undefined,
beginPath: () => undefined,
moveTo: () => undefined,
lineTo: () => undefined,
}) as unknown as CanvasRenderingContext2D;
});
function pl() {
const p = new AudioPlayerPlugin({});
(p as unknown as { config: { sampleRate: number } }).config = {
sampleRate: 48_000,
};
(p as unknown as { buffer: Float32Array }).buffer = new Float32Array(
48_000 * 2
);
return p;
}
describe("<PremiumPlayer>", () => {
it("renders player + visualizer + meter", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<PremiumPlayer plugin={pl()} />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(container.querySelector("canvas")).not.toBeNull();
expect(container.querySelector(".rax-master-meter")).not.toBeNull();
});
it("forwards custom bus name to the visualiser + meter", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<PremiumPlayer plugin={pl()} busName="auxA" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(
container.querySelector("canvas[data-bus='auxA']")
).not.toBeNull();
expect(
container.querySelector(".rax-master-meter[data-bus='auxA']")
).not.toBeNull();
});
it("forwards className", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(
<PremiumPlayer plugin={pl()} className="hero-player" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(
container.querySelector(".rax-premium-player.hero-player")
).not.toBeNull();
});
it("default className is rax-premium-player", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<PremiumPlayer plugin={pl()} />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
expect(container.querySelector(".rax-premium-player")).not.toBeNull();
});
it("accepts a src prop", { timeout: 30000 }, async () => {
const prev = globalThis.fetch;
globalThis.fetch = (async () =>
({
arrayBuffer: async () => new ArrayBuffer(4096),
}) as unknown as Response) as typeof prev;
try {
renderWithProvider(
<PremiumPlayer plugin={pl()} src="https://x/y.bin" />
);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
} finally {
globalThis.fetch = prev;
}
});
});

View File

@@ -0,0 +1,43 @@
import type { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import type { ReactNode } from "react";
import { AudioPlayer } from "../audio-player/audio-player.js";
import { AudioVisualizer } from "../audio-visualizer/audio-visualizer.js";
import { MasterMeter } from "../master-meter/master-meter.js";
export interface PremiumPlayerProps {
readonly plugin: AudioPlayerPlugin;
readonly src?: string;
readonly className?: string;
/** Bus to read for the visualiser + meter. Defaults to `"master"`. */
readonly busName?: string;
}
/**
* Full-featured player composing `<AudioPlayer>` with a visualiser
* and a master meter for VU monitoring. Suitable for media-player
* style surfaces that want the whole instrument panel without manual
* wiring.
*/
export function PremiumPlayer(props: PremiumPlayerProps): ReactNode {
const { plugin, src, className, busName } = props;
const cls =
className === undefined
? "rax-premium-player"
: `rax-premium-player ${className}`;
return (
<section className={cls} aria-label="Premium player">
<AudioPlayer
plugin={plugin}
{...(src !== undefined ? { src } : {})}
/>
<AudioVisualizer
{...(busName !== undefined ? { busName } : {})}
mode="waveform"
/>
<MasterMeter
{...(busName !== undefined ? { busName } : {})}
channels={2}
/>
</section>
);
}

View File

@@ -0,0 +1,5 @@
export {
PresetManager,
type PresetManagerProps,
type PresetStoreWithListing,
} from "./preset-manager.js";

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,112 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import type {
ChainSnapshot,
PluginChain,
PresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base";
import { useEffect, useState } from "react";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import { AudioProvider } from "../../provider/audio-provider.js";
import type { PresetStore } from "../../provider/provider-options.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { PresetManager } from "./preset-manager.js";
const meta = {
title: "react-audio/PresetManager",
component: PresetManager,
} satisfies Meta<typeof PresetManager>;
export default meta;
type Story = StoryObj<typeof meta>;
function makeStoryStore() {
const entries: {
kind: "plugin" | "chain";
id: string;
payload: PresetSnapshot | ChainSnapshot;
}[] = [];
return {
entries,
async save(kind: "plugin" | "chain", id: string, payload: never) {
entries.push({ kind, id, payload });
},
async load(kind: "plugin" | "chain", id: string) {
return entries.find((e) => e.kind === kind && e.id === id)?.payload;
},
async list(kind: "plugin" | "chain") {
return entries.filter((e) => e.kind === kind).map((e) => e.id);
},
async delete(kind: "plugin" | "chain", id: string) {
const i = entries.findIndex((e) => e.kind === kind && e.id === id);
if (i >= 0) entries.splice(i, 1);
},
} as unknown as PresetStore;
}
function ChainHarness(props: {
children: (chain: PluginChain) => React.ReactNode;
}) {
const { chains } = useProviderStateOrThrow();
const [chain] = useState(() => chains.getOrCreate("story-track"));
useEffect(() => {
// Lifecycle parity with the real workspace
}, []);
return <>{props.children(chain)}</>;
}
function Frame(props: { children: React.ReactNode; store?: PresetStore }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
presetStore: props.store,
}}
>
<div style={{ padding: 24, maxWidth: 520 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => {
const store = makeStoryStore();
return (
<Frame store={store}>
<ChainHarness>
{(c) => <PresetManager chain={c} />}
</ChainHarness>
</Frame>
);
},
};
export const Interactive: Story = {
render: () => {
const store = makeStoryStore();
return (
<Frame store={store}>
<ChainHarness>
{(c) => (
<div>
<PresetManager chain={c} />
<p style={{ color: "#888", marginTop: 12 }}>
Type a name, click Save, then Load it back.
</p>
</div>
)}
</ChainHarness>
</Frame>
);
},
};
export const EdgeNoStore: Story = {
render: () => (
<Frame>
<ChainHarness>{(c) => <PresetManager chain={c} />}</ChainHarness>
</Frame>
),
};

View File

@@ -0,0 +1,596 @@
import type {
ChainSnapshot,
PluginChain,
PresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
afterEach(() => cleanup());
import { useEffect, useState } from "react";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import type { PresetStore } from "../../provider/provider-options.js";
import {
makeFakePlugin,
renderWithProvider,
waitForStableProvider,
} from "../../test-utils.js";
import {
PresetManager,
type PresetStoreWithListing,
} from "./preset-manager.js";
interface StoredEntry {
kind: "plugin" | "chain";
id: string;
payload: PresetSnapshot | ChainSnapshot;
}
function makeMemoryStore(): PresetStoreWithListing & {
readonly entries: ReadonlyArray<StoredEntry>;
} {
const entries: StoredEntry[] = [];
return {
get entries() {
return entries;
},
async save(kind, id, payload) {
const existing = entries.findIndex(
(e) => e.kind === kind && e.id === id
);
if (existing >= 0) {
entries.splice(existing, 1);
}
entries.push({ kind, id, payload });
},
async load(kind, id) {
return entries.find((e) => e.kind === kind && e.id === id)?.payload;
},
async list(kind) {
return entries.filter((e) => e.kind === kind).map((e) => e.id);
},
async delete(kind, id) {
const idx = entries.findIndex(
(e) => e.kind === kind && e.id === id
);
if (idx >= 0) entries.splice(idx, 1);
},
};
}
interface HarnessChildProps {
readonly chain: PluginChain;
}
function ChainHarness({
trackId,
onReady,
children,
}: {
trackId: string;
onReady: (chain: PluginChain) => void;
children: (chain: PluginChain) => React.ReactNode;
}) {
const { chains } = useProviderStateOrThrow();
const [chain] = useState(() => chains.getOrCreate(trackId));
useEffect(() => {
onReady(chain);
}, [chain, onReady]);
return <>{children(chain)}</>;
}
function HarnessUI(props: HarnessChildProps) {
return <PresetManager chain={props.chain} />;
}
describe("<PresetManager>", () => {
it("shows a helpful empty state when no preset store is configured", {
timeout: 30000,
}, async () => {
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="no-store" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>
);
await waitForStableProvider(() => captured);
await waitFor(() =>
expect(
screen.getByText(/No preset store is configured/i)
).toBeDefined()
);
});
it("save persists a snapshot that load round-trips", {
timeout: 30000,
}, async () => {
const store = makeMemoryStore();
let captured: PluginChain | undefined;
// Plugin factory whose minted plugins use a stable id matching
// the registry's pluginType — so chain.restore can re-create
// them.
const STABLE_ID = "alpha";
const stableFactory = () => {
const p = makeFakePlugin({ id: STABLE_ID });
Object.defineProperty(p, "io", {
value: {
inputs: [{ count: 2 }],
outputs: [{ count: 2 }],
},
configurable: true,
});
return p;
};
renderWithProvider(
<ChainHarness trackId="round-trip" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{
options: {
presetStore: store as unknown as PresetStore,
plugins: [
{
pluginType: STABLE_ID,
factory: stableFactory,
},
],
},
}
);
await waitForStableProvider(() => captured);
const chain = captured!;
// Set up the chain so chain.snapshot() embeds a valid blockSize
// (parseChainSnapshot requires positive sampleRate/blockSize/
// channels and PluginChain leaves `config` undefined until
// setup() runs).
await chain.setup(
{ channels: 2, sampleRate: 48000, blockSize: 128 },
chain.currentAmbient
);
// Add a plugin via the factory so its metadata.id === pluginType.
chain.add(stableFactory());
chain.bypass(0, true);
expect(chain.plugins.length).toBe(1);
// Save preset.
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: "demo" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(store.entries.length).toBe(1));
// Wipe the chain.
chain.remove(0);
expect(chain.plugins.length).toBe(0);
// Load — restore through the UI.
await waitFor(() =>
screen.getByRole("button", { name: /^Load demo$/ })
);
fireEvent.click(screen.getByRole("button", { name: /^Load demo$/ }));
await waitFor(() => {
expect(chain.plugins.length).toBe(1);
const slot = chain.findById(STABLE_ID);
expect(slot?.plugin.metadata.id).toBe(STABLE_ID);
});
});
it("load failure surfaces AUDIO_FX.CHAIN_SLOT_NOT_FOUND", {
timeout: 30000,
}, async () => {
const store = makeMemoryStore();
// Pre-seed the store with a snapshot that references a plugin
// type the registry has no factory for.
await store.save("chain", "ghost", {
id: "ghost-trail",
name: "ghost-trail",
channels: 2,
sampleRate: 48000,
blockSize: 128,
plugins: [
{
pluginType: "non-existent-plugin",
instanceId: "ghost-instance-1",
bypassed: false,
preset: {
schemaVersion: 1,
pluginType: "non-existent-plugin",
parameters: {},
},
},
],
} as unknown as ChainSnapshot);
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="ghost-trail" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Load ghost$/ })
).toBeDefined()
);
fireEvent.click(screen.getByRole("button", { name: /^Load ghost$/ }));
await waitFor(() => {
const toast = screen.getByRole("status");
expect(toast.getAttribute("data-code")).toBe(
"AUDIO_FX.CHAIN_SLOT_NOT_FOUND"
);
});
});
it("Save is disabled until the name input is non-empty", {
timeout: 30000,
}, async () => {
const store = makeMemoryStore();
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="disabled" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
const button = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: " " },
});
expect(button.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: "ok" },
});
await waitFor(() => expect(button.disabled).toBe(false));
});
it("Delete removes the preset from the store and the list", {
timeout: 30000,
}, async () => {
const store = makeMemoryStore();
await store.save("chain", "first", {
id: "del-track",
name: "del-track",
channels: 2,
sampleRate: 48000,
blockSize: 128,
plugins: [],
} as unknown as ChainSnapshot);
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="del-track" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
await waitFor(() =>
screen.getByRole("button", { name: /^Delete first$/ })
);
fireEvent.click(screen.getByRole("button", { name: /^Delete first$/ }));
await waitFor(() => expect(store.entries.length).toBe(0));
await waitFor(() =>
expect(screen.getByText("No presets saved yet.")).toBeDefined()
);
});
it("Save with a non-listing store optimistically adds to the local list", {
timeout: 30000,
}, async () => {
const minimal: PresetStore = {
async save() {},
async load() {
return undefined;
},
};
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="opt-track" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: minimal } }
);
await waitForStableProvider(() => captured);
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: "manual" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Load manual$/ })
).toBeDefined()
);
// Save the same name again — covers the
// `ids.includes(trimmed) ? ids : [...ids, trimmed]` true branch.
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: "manual" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// Still exactly one preset item for "manual" in the list.
await waitFor(() => {
const allMatching = screen.getAllByRole("button", {
name: /^Load manual$/,
});
expect(allMatching.length).toBe(1);
});
});
it("Save rejection surfaces the underlying error code", {
timeout: 30000,
}, async () => {
const broken: PresetStore = {
async save() {
throw new Error("disk full");
},
async load() {
return undefined;
},
};
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness
trackId="broken-track"
onReady={(c) => (captured = c)}
>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: broken } }
);
await waitForStableProvider(() => captured);
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: "err" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
const toast = screen.getByRole("status");
expect(toast.getAttribute("data-code")).toBe(
"REACT_AUDIO.PRESET_STORE_FAILED"
);
});
});
it("Delete rejection surfaces the underlying error code", {
timeout: 30000,
}, async () => {
const store: PresetStoreWithListing = {
async save() {},
async load() {
return undefined;
},
async list() {
return ["only"];
},
async delete() {
throw new Error("nope");
},
};
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="del-fail" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
await waitFor(() =>
screen.getByRole("button", { name: /^Delete only$/ })
);
fireEvent.click(screen.getByRole("button", { name: /^Delete only$/ }));
await waitFor(() => {
const toast = screen.getByRole("status");
expect(toast.getAttribute("data-code")).toBeDefined();
});
});
it("List rejection surfaces the error in a toast", {
timeout: 30000,
}, async () => {
const store: PresetStoreWithListing = {
async save() {},
async load() {
return undefined;
},
async list() {
throw new Error("listing busted");
},
};
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="list-fail" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
await waitFor(() => screen.getByRole("status"));
});
it("Unmount during list fetch cancels the pending then-callback", {
timeout: 30000,
}, async () => {
let resolveFn: (ids: string[]) => void = () => undefined;
const store: PresetStoreWithListing = {
async save() {},
async load() {
return undefined;
},
async list() {
return new Promise<string[]>((r) => {
resolveFn = r;
});
},
};
let captured: PluginChain | undefined;
const { unmount } = renderWithProvider(
<ChainHarness
trackId="unmount-list"
onReady={(c) => (captured = c)}
>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
unmount();
// Resolve after unmount; cancelled guard prevents state update
resolveFn(["late-arrival"]);
await new Promise((r) => setTimeout(r, 20));
// No assertion is needed; the test exercises the cancelled
// branch by not crashing under the post-unmount resolution.
expect(true).toBe(true);
});
it("Unmount during list fetch cancels the pending catch-callback", {
timeout: 30000,
}, async () => {
let rejectFn: (err: Error) => void = () => undefined;
const store: PresetStoreWithListing = {
async save() {},
async load() {
return undefined;
},
async list() {
return new Promise<string[]>((_r, rj) => {
rejectFn = rj;
});
},
};
let captured: PluginChain | undefined;
const { unmount } = renderWithProvider(
<ChainHarness
trackId="unmount-list-err"
onReady={(c) => (captured = c)}
>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
unmount();
rejectFn(new Error("post-unmount rejection"));
await new Promise((r) => setTimeout(r, 20));
expect(true).toBe(true);
});
it("Non-Error rejection from the store falls back to AUDIO_FX.UNKNOWN", {
timeout: 30000,
}, async () => {
// Although save() always wraps the rejection in
// REACT_AUDIO.PRESET_STORE_FAILED, list() doesn't — so a list
// that rejects with a non-Error value hits the final fallback
// branch.
const store: PresetStoreWithListing = {
async save() {},
async load() {
return undefined;
},
async list() {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw "stringly-typed";
},
};
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness
trackId="non-err-list"
onReady={(c) => (captured = c)}
>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
await waitFor(() => {
const toast = screen.getByRole("status");
expect(toast.getAttribute("data-code")).toBe("AUDIO_FX.UNKNOWN");
expect(toast.textContent).toContain("stringly-typed");
});
});
it("Toast dismiss button clears the toast", {
timeout: 30000,
}, async () => {
const broken: PresetStore = {
async save() {
throw new Error("nope");
},
async load() {
return undefined;
},
};
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="dis-track" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: broken } }
);
await waitForStableProvider(() => captured);
fireEvent.change(screen.getByLabelText("Preset name"), {
target: { value: "x" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => screen.getByRole("status"));
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
});
it("Empty draft name save is a no-op", {
timeout: 30000,
}, async () => {
const store = makeMemoryStore();
let captured: PluginChain | undefined;
renderWithProvider(
<ChainHarness trackId="empty-name" onReady={(c) => (captured = c)}>
{(c) => <HarnessUI chain={c} />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
// Save() is only callable through the button, which is disabled
// for empty input — drive it directly by querying the
// underlying onClick handler. fireEvent.click on the disabled
// button still propagates but our handler bails on empty name.
const button = screen.getByRole("button", { name: "Save" });
fireEvent.click(button); // no-op since disabled
expect(store.entries.length).toBe(0);
});
it("Forwards className to the wrapper", {
timeout: 30000,
}, async () => {
const store = makeMemoryStore();
let captured: PluginChain | undefined;
const { container } = renderWithProvider(
<ChainHarness trackId="cls-track" onReady={(c) => (captured = c)}>
{(c) => <PresetManager chain={c} className="extra" />}
</ChainHarness>,
{ options: { presetStore: store as unknown as PresetStore } }
);
await waitForStableProvider(() => captured);
expect(container.querySelector(".rax-presets.extra")).not.toBeNull();
});
it("Missing-store branch also forwards className", {
timeout: 30000,
}, async () => {
let captured: PluginChain | undefined;
const { container } = renderWithProvider(
<ChainHarness trackId="cls-missing" onReady={(c) => (captured = c)}>
{(c) => <PresetManager chain={c} className="mm" />}
</ChainHarness>
);
await waitForStableProvider(() => captured);
expect(
container.querySelector(".rax-presets.rax-presets--missing.mm")
).not.toBeNull();
});
});

View File

@@ -0,0 +1,259 @@
import {
AudioFxError,
type PluginChain,
} from "@suaveplan/audio-fx-plugin-base";
import { SuaveplanError } from "@suaveplan/error";
import {
type ChangeEvent,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { useChainSnapshot } from "../../hooks/use-chain-snapshot/use-chain-snapshot.js";
import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js";
import type { PresetStore } from "../../provider/provider-options.js";
export interface PresetManagerProps {
readonly chain: PluginChain;
/** Optional CSS class forwarded to the wrapper. */
readonly className?: string;
}
interface ToastState {
readonly id: number;
readonly code: string;
readonly message: string;
}
/**
* Optional extension to the `PresetStore` interface understood by
* `<PresetManager>`. Stores that supply `list()` get a fully
* reactive preset list; stores that only implement `save`/`load`
* still work but the visible preset list only reflects what the
* current `<PresetManager>` instance has saved during its lifetime.
*/
export interface PresetStoreWithListing extends PresetStore {
list?(kind: "plugin" | "chain"): Promise<ReadonlyArray<string>>;
delete?(kind: "plugin" | "chain", id: string): Promise<void>;
}
function asAudioFxError(err: unknown): {
readonly code: string;
readonly message: string;
} {
if (err instanceof AudioFxError) {
return { code: err.code as string, message: err.message };
}
if (err instanceof SuaveplanError) {
// Includes ReactAudioError (REACT_AUDIO.*) and any other
// sibling SuaveplanError subclass that may bubble up here.
return {
code: (err as { code: unknown }).code as string,
message: err.message,
};
}
if (err instanceof Error) {
return { code: "AUDIO_FX.UNKNOWN", message: err.message };
}
return { code: "AUDIO_FX.UNKNOWN", message: String(err) };
}
/**
* Save / load / delete UI for `ChainSnapshot`s, layered over the
* provider's `PresetStore`. The save path validates the snapshot
* against the `ChainSnapshotSchema` re-exported from
* `@suaveplan/audio-fx-plugin-base` before handing it to the store;
* the load path delegates to `chain.restore(snapshot,
* provider.registry)` which throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`
* when the snapshot references a plugin type no longer present in
* the registry. Every error surfaces as a non-blocking toast carrying
* the underlying code.
*/
export function PresetManager(props: PresetManagerProps): ReactNode {
const { chain, className } = props;
const { presetStore } = useProviderStateOrThrow();
const snapshot = useChainSnapshot(chain);
const [draftName, setDraftName] = useState("");
const [savedIds, setSavedIds] = useState<ReadonlyArray<string>>([]);
const [toast, setToast] = useState<ToastState | null>(null);
// Re-render counter — bumps every time we successfully save or
// delete so a subscribing parent (and our own list re-fetch) sees
// the change without polling.
const [_version, setVersion] = useState(0);
const store = presetStore as PresetStoreWithListing | undefined;
const showError = useCallback((err: unknown) => {
const info = asAudioFxError(err);
setToast({ id: Date.now(), code: info.code, message: info.message });
}, []);
// Re-fetch the saved-id list whenever the version bumps or the
// store reference changes. Stores without `list()` fall back to
// the local "just-saved" set we track in state, which we update
// optimistically on every successful save / delete.
useEffect(() => {
let cancelled = false;
if (store?.list === undefined) return;
store
.list("chain")
.then((ids) => {
if (!cancelled) setSavedIds(ids);
})
.catch((err) => {
if (!cancelled) showError(err);
});
return () => {
cancelled = true;
};
}, [store, showError]);
const dismissToast = useCallback(() => setToast(null), []);
const onSave = useCallback(async () => {
const trimmed = draftName.trim();
try {
await snapshot.save(trimmed);
setSavedIds((ids) =>
ids.includes(trimmed) ? ids : [...ids, trimmed]
);
setDraftName("");
setVersion((v) => v + 1);
} catch (err) {
showError(err);
}
}, [draftName, snapshot, showError]);
const onLoad = useCallback(
async (id: string) => {
try {
await snapshot.load(id);
} catch (err) {
showError(err);
}
},
[snapshot, showError]
);
const onDelete = useCallback(
async (id: string) => {
try {
await store?.delete?.("chain", id);
setSavedIds((ids) => ids.filter((x) => x !== id));
setVersion((v) => v + 1);
} catch (err) {
showError(err);
}
},
[store, showError]
);
const onDraftChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setDraftName(event.target.value);
},
[]
);
if (presetStore === undefined) {
return (
<div
className={
className === undefined
? "rax-presets rax-presets--missing"
: `rax-presets rax-presets--missing ${className}`
}
>
<p>
No preset store is configured on the provider. Add a{" "}
<code>presetStore</code> option to
<code>&lt;AudioProvider&gt;</code> to enable preset save /
load.
</p>
</div>
);
}
return (
<div
className={
className === undefined
? "rax-presets"
: `rax-presets ${className}`
}
data-chain-id={chain.id}
>
<header className="rax-presets__header">
<h3>Chain presets</h3>
<p className="rax-presets__count">{savedIds.length} saved</p>
</header>
<div className="rax-presets__save-row">
<label htmlFor="rax-presets__name">Preset name</label>
<input
id="rax-presets__name"
type="text"
value={draftName}
placeholder="My preset"
onChange={onDraftChange}
/>
<button
type="button"
disabled={draftName.trim().length === 0}
onClick={onSave}
>
Save
</button>
</div>
<ul className="rax-presets__list" aria-label="Saved presets">
{savedIds.length === 0 ? (
<li className="rax-presets__empty">
No presets saved yet.
</li>
) : (
savedIds.map((id) => (
<li key={id} className="rax-presets__item">
<span className="rax-presets__name">{id}</span>
<button
type="button"
onClick={() => onLoad(id)}
aria-label={`Load ${id}`}
>
Load
</button>
<button
type="button"
onClick={() => onDelete(id)}
aria-label={`Delete ${id}`}
>
Delete
</button>
</li>
))
)}
</ul>
{toast === null ? null : (
<div
role="status"
className="rax-presets__toast"
data-code={toast.code}
>
<span className="rax-presets__toast-code">
{toast.code}
</span>
<span className="rax-presets__toast-message">
{toast.message}
</span>
<button
type="button"
onClick={dismissToast}
aria-label="Dismiss"
>
×
</button>
</div>
)}
</div>
);
}

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,46 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { TrackList } from "./track-list.js";
const meta = {
title: "react-audio/TrackList",
component: TrackList,
} satisfies Meta<typeof TrackList>;
export default meta;
type Story = StoryObj<typeof meta>;
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<TrackList />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<TrackList className="studio-tracks" />
</Frame>
),
};
export const EdgeEmpty: Story = {
render: () => (
<Frame>
<TrackList hideAddTrack />
</Frame>
),
};

View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { useTimeline } from "../../hooks/use-timeline/use-timeline.js";
import { renderWithProvider } from "../../test-utils.js";
import { TrackList } from "./track-list.js";
afterEach(() => cleanup());
function Seeder({ count }: { count: number }) {
const timeline = useTimeline();
useEffect(() => {
for (let i = 0; i < count; i++) {
timeline.addTrack({ name: `Track ${i}` });
}
}, [timeline, count]);
return null;
}
describe("<TrackList>", () => {
it("renders against a real TimelineEngine", {
timeout: 30000,
}, async () => {
renderWithProvider(
<>
<Seeder count={2} />
<TrackList />
</>
);
await waitFor(() => screen.getByText("Tracks"));
await waitFor(() => {
const rows = screen.getAllByRole("listitem");
expect(rows.length).toBe(2);
});
await waitFor(() => {
expect(screen.getByDisplayValue("Track 0")).toBeDefined();
expect(screen.getByDisplayValue("Track 1")).toBeDefined();
});
});
it("shows empty placeholder when no tracks exist", {
timeout: 30000,
}, async () => {
renderWithProvider(<TrackList />);
await waitFor(() => screen.getByText("Tracks"));
expect(screen.getByText("No tracks.")).toBeDefined();
});
it("Add track button creates a new track", {
timeout: 30000,
}, async () => {
renderWithProvider(<TrackList />);
await waitFor(() => screen.getByText("Tracks"));
fireEvent.click(screen.getByRole("button", { name: "Add track" }));
await waitFor(() => {
const items = screen.getAllByRole("listitem");
expect(items.length).toBe(1);
});
});
it("hideAddTrack=true hides the add button", {
timeout: 30000,
}, async () => {
renderWithProvider(<TrackList hideAddTrack={true} />);
await waitFor(() => screen.getByText("Tracks"));
expect(screen.queryByRole("button", { name: "Add track" })).toBeNull();
});
it("forwards className to the wrapper", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<TrackList className="custom-list" />
);
await waitFor(() => screen.getByText("Tracks"));
expect(
container.querySelector(".rax-track-list.custom-list")
).not.toBeNull();
});
it("count badge reflects the live track count", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(<TrackList />);
await waitFor(() => screen.getByText("Tracks"));
const count = container.querySelector(
".rax-track-list__count"
) as HTMLElement;
expect(count.textContent).toBe("0");
fireEvent.click(screen.getByRole("button", { name: "Add track" }));
await waitFor(() => expect(count.textContent).toBe("1"));
});
});

View File

@@ -0,0 +1,73 @@
import type { Track } from "@suaveplan/audio-timeline";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useTimeline } from "../../hooks/use-timeline/use-timeline.js";
import { TrackRow } from "../track-row/track-row.js";
export interface TrackListProps {
readonly className?: string;
/** Hide the "add track" button. */
readonly hideAddTrack?: boolean;
}
/**
* Renders the timeline engine's full track list. Subscribes to
* `trackChange` events through the engine event surface and re-renders
* with a fresh snapshot whenever a track is added, removed, or
* updated. Each row is a `<TrackRow>` instance.
*/
export function TrackList(props: TrackListProps): ReactNode {
const { className, hideAddTrack } = props;
const timeline = useTimeline();
const [tracks, setTracks] = useState<ReadonlyArray<Track>>(
() => timeline.getState().tracks
);
useEffect(() => {
const refresh = () => setTracks(timeline.getState().tracks);
const off = timeline.on("trackChange", refresh);
// Snapshot on mount in case tracks changed before subscription.
refresh();
return () => off();
}, [timeline]);
const onAddTrack = useCallback(() => {
timeline.addTrack();
setTracks(timeline.getState().tracks);
}, [timeline]);
return (
<section
className={
className === undefined
? "rax-track-list"
: `rax-track-list ${className}`
}
aria-label="Track list"
>
<header className="rax-track-list__header">
<h2 className="rax-track-list__title">Tracks</h2>
{hideAddTrack === true ? null : (
<button
type="button"
className="rax-track-list__add"
onClick={onAddTrack}
>
Add track
</button>
)}
<span className="rax-track-list__count" aria-live="polite">
{tracks.length}
</span>
</header>
{tracks.length === 0 ? (
<p className="rax-track-list__empty">No tracks.</p>
) : (
<ul className="rax-track-list__rows">
{tracks.map((t) => (
<TrackRow key={t.id} trackId={t.id} />
))}
</ul>
)}
</section>
);
}

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,48 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { TrackRow } from "./track-row.js";
const meta = {
title: "react-audio/TrackRow",
component: TrackRow,
} satisfies Meta<typeof TrackRow>;
export default meta;
type Story = StoryObj<typeof meta>;
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>
<ul>{props.children}</ul>
</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<TrackRow trackId="demo-1" />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<TrackRow trackId="demo-2" className="studio-row" />
</Frame>
),
};
export const EdgeMissing: Story = {
render: () => (
<Frame>
<TrackRow trackId="does-not-exist" />
</Frame>
),
};

View File

@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { useEffect, useState } from "react";
import { useTimeline } from "../../hooks/use-timeline/use-timeline.js";
import { renderWithProvider } from "../../test-utils.js";
import { TrackRow } from "./track-row.js";
afterEach(() => cleanup());
function SeedAndRender({
trackId,
initialName,
hideRemove,
className,
}: {
trackId: string;
initialName?: string;
hideRemove?: boolean;
className?: string;
}) {
const timeline = useTimeline();
const [seeded, setSeeded] = useState(false);
useEffect(() => {
timeline.addTrack({ id: trackId, name: initialName ?? trackId });
setSeeded(true);
}, [timeline, trackId, initialName]);
if (!seeded) return null;
return (
<ul>
<TrackRow
trackId={trackId}
{...(hideRemove !== undefined ? { hideRemove } : {})}
{...(className !== undefined ? { className } : {})}
/>
</ul>
);
}
describe("<TrackRow>", () => {
it("renders track name + controls", { timeout: 30000 }, async () => {
renderWithProvider(<SeedAndRender trackId="t1" initialName="Hello" />);
await waitFor(() =>
expect(
(screen.getByLabelText("Track t1 name") as HTMLInputElement)
.value
).toBe("Hello")
);
expect(screen.getByRole("button", { name: "Mute" })).toBeDefined();
expect(screen.getByRole("button", { name: "Solo" })).toBeDefined();
expect(screen.getByLabelText("Track t1 volume")).toBeDefined();
expect(screen.getByLabelText("Track t1 pan")).toBeDefined();
});
it("mute / solo toggles flip aria-pressed and live label", {
timeout: 30000,
}, async () => {
renderWithProvider(<SeedAndRender trackId="t1" />);
await waitFor(() => screen.getByRole("button", { name: "Mute" }));
fireEvent.click(screen.getByRole("button", { name: "Mute" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Muted" })).toBeDefined()
);
fireEvent.click(screen.getByRole("button", { name: "Solo" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Soloed" })).toBeDefined()
);
});
it("volume + pan + name inputs propagate to the timeline", {
timeout: 30000,
}, async () => {
renderWithProvider(<SeedAndRender trackId="t1" />);
await waitFor(() => screen.getByLabelText("Track t1 volume"));
fireEvent.change(screen.getByLabelText("Track t1 volume"), {
target: { value: "0.5" },
});
fireEvent.change(screen.getByLabelText("Track t1 pan"), {
target: { value: "-0.5" },
});
fireEvent.change(screen.getByLabelText("Track t1 name"), {
target: { value: "Renamed" },
});
await waitFor(() =>
expect(
(screen.getByLabelText("Track t1 name") as HTMLInputElement)
.value
).toBe("Renamed")
);
// Non-finite inputs are ignored
fireEvent.change(screen.getByLabelText("Track t1 volume"), {
target: { value: "abc" },
});
fireEvent.change(screen.getByLabelText("Track t1 pan"), {
target: { value: "abc" },
});
});
it("remove button removes the track and shows tombstone", {
timeout: 30000,
}, async () => {
renderWithProvider(<SeedAndRender trackId="t1" />);
await waitFor(() => screen.getByLabelText("Remove track t1"));
fireEvent.click(screen.getByLabelText("Remove track t1"));
await waitFor(() =>
expect(screen.getByText("Track removed")).toBeDefined()
);
});
it("hideRemove suppresses the remove button", {
timeout: 30000,
}, async () => {
renderWithProvider(<SeedAndRender trackId="t1" hideRemove={true} />);
await waitFor(() => screen.getByLabelText("Track t1 name"));
expect(screen.queryByLabelText("Remove track t1")).toBeNull();
});
it("renders the tombstone when trackId does not exist", {
timeout: 30000,
}, async () => {
function NoTrack() {
return (
<ul>
<TrackRow trackId="missing" />
</ul>
);
}
renderWithProvider(<NoTrack />);
await waitFor(() =>
expect(screen.getByText("Track removed")).toBeDefined()
);
});
it("mute/solo unflip when clicked twice", {
timeout: 30000,
}, async () => {
renderWithProvider(<SeedAndRender trackId="t1" />);
await waitFor(() => screen.getByRole("button", { name: "Mute" }));
fireEvent.click(screen.getByRole("button", { name: "Mute" }));
await waitFor(() => screen.getByRole("button", { name: "Muted" }));
fireEvent.click(screen.getByRole("button", { name: "Muted" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Mute" })).toBeDefined()
);
fireEvent.click(screen.getByRole("button", { name: "Solo" }));
await waitFor(() => screen.getByRole("button", { name: "Soloed" }));
fireEvent.click(screen.getByRole("button", { name: "Soloed" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Solo" })).toBeDefined()
);
});
it("non-numeric volume/pan input ignored", {
timeout: 30000,
}, async () => {
renderWithProvider(<SeedAndRender trackId="t1" />);
await waitFor(() => screen.getByLabelText("Track t1 volume"));
const vol = screen.getByLabelText(
"Track t1 volume"
) as HTMLInputElement;
Object.defineProperty(vol, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(vol);
const pan = screen.getByLabelText("Track t1 pan") as HTMLInputElement;
Object.defineProperty(pan, "value", {
configurable: true,
get: () => "not-a-number",
});
fireEvent.change(pan);
});
it("forwards className to the active and tombstone variants", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<SeedAndRender trackId="t1" className="custom-row" />
);
await waitFor(() => screen.getByLabelText("Track t1 name"));
expect(
container.querySelector(".rax-track-row.custom-row")
).not.toBeNull();
function NoTrack() {
return (
<ul>
<TrackRow trackId="x" className="ghost-row" />
</ul>
);
}
const { container: c2 } = renderWithProvider(<NoTrack />);
await waitFor(() => screen.getByText("Track removed"));
expect(
c2.querySelector(".rax-track-row--tombstone.ghost-row")
).not.toBeNull();
});
});

View File

@@ -0,0 +1,157 @@
import type { Track } from "@suaveplan/audio-timeline";
import { type ChangeEvent, type ReactNode, useCallback } from "react";
import {
type UseTrackResult,
useTrack,
} from "../../hooks/use-track/use-track.js";
export interface TrackRowProps {
readonly trackId: string;
readonly className?: string;
/** Hide the remove button. Defaults to `false`. */
readonly hideRemove?: boolean;
}
/**
* Single-row view of a `Track`. Subscribes to track changes through
* `useTrack` and renders mute / solo / volume / pan / name controls
* bound to the live track snapshot. When the track is removed the row
* collapses to a tombstone marker so the row position is preserved
* during pending re-renders.
*/
export function TrackRow(props: TrackRowProps): ReactNode {
const { trackId, className, hideRemove } = props;
const handle = useTrack(trackId);
if (handle.track === undefined) {
return (
<li
className={
className === undefined
? "rax-track-row rax-track-row--tombstone"
: `rax-track-row rax-track-row--tombstone ${className}`
}
data-track-id={trackId}
>
<span aria-live="polite">Track removed</span>
</li>
);
}
return (
<ActiveRow
track={handle.track}
handle={handle}
{...(className !== undefined ? { className } : {})}
{...(hideRemove !== undefined ? { hideRemove } : {})}
/>
);
}
interface ActiveRowProps {
readonly track: Track;
readonly handle: UseTrackResult;
readonly className?: string;
readonly hideRemove?: boolean;
}
function ActiveRow(props: ActiveRowProps): ReactNode {
const { track, handle, className, hideRemove } = props;
const onMute = useCallback(() => {
handle.update({ muted: !track.muted });
}, [handle, track.muted]);
const onSolo = useCallback(() => {
handle.update({ soloed: !track.soloed });
}, [handle, track.soloed]);
const onVolume = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = Number.parseFloat(e.target.value);
/* istanbul ignore else -- non-finite branch covered in tests */
if (Number.isFinite(next)) handle.update({ volume: next });
},
[handle]
);
const onPan = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = Number.parseFloat(e.target.value);
/* istanbul ignore else -- non-finite branch covered in tests */
if (Number.isFinite(next)) handle.update({ pan: next });
},
[handle]
);
const onName = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
handle.update({ name: e.target.value });
},
[handle]
);
const cls =
className === undefined
? "rax-track-row"
: `rax-track-row ${className}`;
return (
<li
className={cls}
data-track-id={track.id}
data-muted={track.muted ? "true" : "false"}
data-soloed={track.soloed ? "true" : "false"}
>
<input
type="text"
className="rax-track-row__name"
aria-label={`Track ${track.id} name`}
value={track.name}
onChange={onName}
/>
<button
type="button"
className="rax-track-row__mute"
aria-pressed={track.muted}
onClick={onMute}
>
{track.muted ? "Muted" : "Mute"}
</button>
<button
type="button"
className="rax-track-row__solo"
aria-pressed={track.soloed}
onClick={onSolo}
>
{track.soloed ? "Soloed" : "Solo"}
</button>
<label className="rax-track-row__volume">
<span>Vol</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={track.volume}
onChange={onVolume}
aria-label={`Track ${track.id} volume`}
/>
</label>
<label className="rax-track-row__pan">
<span>Pan</span>
<input
type="range"
min={-1}
max={1}
step={0.01}
value={track.pan}
onChange={onPan}
aria-label={`Track ${track.id} pan`}
/>
</label>
{hideRemove === true ? null : (
<button
type="button"
className="rax-track-row__remove"
onClick={handle.remove}
aria-label={`Remove track ${track.id}`}
>
×
</button>
)}
</li>
);
}

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,52 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { TransportControls } from "./transport-controls.js";
const meta = {
title: "react-audio/TransportControls",
component: TransportControls,
} satisfies Meta<typeof TransportControls>;
export default meta;
type Story = StoryObj<typeof meta>;
function Frame(props: { readonly children: React.ReactNode }) {
return (
<AudioProvider
options={{
adapter: { context: makeFakeAudioContext() as never },
}}
>
<div style={{ padding: 24 }}>{props.children}</div>
</AudioProvider>
);
}
export const Default: Story = {
render: () => (
<Frame>
<TransportControls />
</Frame>
),
};
export const Interactive: Story = {
render: () => (
<Frame>
<TransportControls enableGlobalHotkeys={true} />
<p style={{ color: "#888", marginTop: 8 }}>
Hotkeys: Space toggles play/pause, Escape stops, L toggles loop.
</p>
</Frame>
),
};
export const EdgeTempoLimits: Story = {
render: () => (
<Frame>
<TransportControls minTempo={60} maxTempo={120} />
</Frame>
),
};

View File

@@ -0,0 +1,171 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProvider } from "../../test-utils.js";
import { TransportControls } from "./transport-controls.js";
afterEach(() => cleanup());
describe("<TransportControls>", () => {
it("renders against a real transport", { timeout: 30000 }, async () => {
renderWithProvider(<TransportControls />);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Play" })).toBeDefined()
);
expect(screen.getByRole("button", { name: "Stop" })).toBeDefined();
expect(screen.getByRole("button", { name: "Loop off" })).toBeDefined();
expect(screen.getByLabelText("Seek position seconds")).toBeDefined();
expect(screen.getByLabelText("Tempo BPM")).toBeDefined();
});
it("play/pause toggles the transport state", {
timeout: 30000,
}, async () => {
renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
fireEvent.click(screen.getByRole("button", { name: "Play" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Pause" })).toBeDefined()
);
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Play" })).toBeDefined()
);
});
it("stop returns to stopped state", { timeout: 30000 }, async () => {
renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
fireEvent.click(screen.getByRole("button", { name: "Play" }));
await waitFor(() => screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Play" })).toBeDefined()
);
});
it("loop toggle flips transport.loop and aria-pressed", {
timeout: 30000,
}, async () => {
renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByRole("button", { name: "Loop off" }));
const loopBtn = screen.getByRole("button", { name: "Loop off" });
fireEvent.click(loopBtn);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Loop on" })
).toBeDefined()
);
});
it("seek input writes to transport (positive values only)", {
timeout: 30000,
}, async () => {
renderWithProvider(<TransportControls initialSeekSeconds={0} />);
await waitFor(() => screen.getByLabelText("Seek position seconds"));
const seekInput = screen.getByLabelText(
"Seek position seconds"
) as HTMLInputElement;
fireEvent.change(seekInput, { target: { value: "1.25" } });
expect(seekInput.value).toBe("1.25");
// Invalid (non-finite) is rejected — value still updates display
fireEvent.change(seekInput, { target: { value: "not-a-number" } });
// Negative is rejected — seek isn't called
fireEvent.change(seekInput, { target: { value: "-3" } });
expect(seekInput.value).toBe("-3");
});
it("tempo input updates BPM signal", { timeout: 30000 }, async () => {
renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByLabelText("Tempo BPM"));
const tempo = screen.getByLabelText("Tempo BPM") as HTMLInputElement;
fireEvent.change(tempo, { target: { value: "140" } });
expect(tempo.value).toBe("140");
// Invalid (NaN) is rejected
fireEvent.change(tempo, { target: { value: "abc" } });
});
it("Space hotkey toggles play/pause (local keydown)", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
const wrapper = container.querySelector(
".rax-transport"
) as HTMLElement;
fireEvent.keyDown(wrapper, { key: " " });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Pause" })).toBeDefined()
);
fireEvent.keyDown(wrapper, { key: "Space" });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Play" })).toBeDefined()
);
fireEvent.keyDown(wrapper, { key: "Spacebar" });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Pause" })).toBeDefined()
);
});
it("Escape hotkey stops, L hotkey toggles loop", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
const wrapper = container.querySelector(
".rax-transport"
) as HTMLElement;
fireEvent.keyDown(wrapper, { key: " " });
await waitFor(() => screen.getByRole("button", { name: "Pause" }));
fireEvent.keyDown(wrapper, { key: "Escape" });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Play" })).toBeDefined()
);
fireEvent.keyDown(wrapper, { key: "L" });
await waitFor(() => screen.getByRole("button", { name: "Loop on" }));
fireEvent.keyDown(wrapper, { key: "l" });
await waitFor(() => screen.getByRole("button", { name: "Loop off" }));
});
it("unhandled keys are ignored", { timeout: 30000 }, async () => {
const { container } = renderWithProvider(<TransportControls />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
const wrapper = container.querySelector(
".rax-transport"
) as HTMLElement;
fireEvent.keyDown(wrapper, { key: "X" });
expect(screen.getByRole("button", { name: "Play" })).toBeDefined();
});
it("global hotkeys when enableGlobalHotkeys=true", {
timeout: 30000,
}, async () => {
renderWithProvider(<TransportControls enableGlobalHotkeys={true} />);
await waitFor(() => screen.getByRole("button", { name: "Play" }));
// Global keydown anywhere (document)
fireEvent.keyDown(globalThis.document, { key: " " });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Pause" })).toBeDefined()
);
// Unhandled global key
fireEvent.keyDown(globalThis.document, { key: "Q" });
});
it("respects custom minTempo/maxTempo and className", {
timeout: 30000,
}, async () => {
const { container } = renderWithProvider(
<TransportControls
className="custom-tc"
minTempo={40}
maxTempo={200}
/>
);
await waitFor(() => screen.getByLabelText("Tempo BPM"));
const tempo = screen.getByLabelText("Tempo BPM") as HTMLInputElement;
expect(tempo.min).toBe("40");
expect(tempo.max).toBe("200");
expect(
container.querySelector(".rax-transport.custom-tc")
).not.toBeNull();
});
});

View File

@@ -0,0 +1,215 @@
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTransport } from "../../hooks/use-transport/use-transport.js";
import { useTransportTime } from "../../hooks/use-transport-time/use-transport-time.js";
export interface TransportControlsProps {
/** Optional class forwarded to the wrapper. */
readonly className?: string;
/** Initial seek input value displayed (seconds). */
readonly initialSeekSeconds?: number;
/** Minimum tempo in BPM (default 20). */
readonly minTempo?: number;
/** Maximum tempo in BPM (default 300). */
readonly maxTempo?: number;
/** When true, the component listens to global `keydown` for hotkeys. */
readonly enableGlobalHotkeys?: boolean;
}
/**
* Transport surface for the provider-owned `Transport`. Exposes
* play / pause / stop / seek / loop / tempo controls bound to the
* provider's transport instance and re-renders against the live
* `position` reported by `useTransportTime`.
*
* Hotkeys (when focused inside the component, or globally if
* `enableGlobalHotkeys` is enabled): `Space` toggles play/pause,
* `Escape` stops, `L` toggles the loop flag.
*/
export function TransportControls(props: TransportControlsProps): ReactNode {
const {
className,
initialSeekSeconds,
minTempo,
maxTempo,
enableGlobalHotkeys,
} = props;
const { transport, play, pause, stop, seek, setTempo } = useTransport();
const seconds = useTransportTime();
const [state, setState] = useState<"stopped" | "playing" | "paused">(
transport.state
);
const [bpm, setBpm] = useState<number>(
transport.bpm.getValueAtTime(transport.seconds)
);
const [loop, setLoopState] = useState<boolean>(transport.loop);
const [seekValue, setSeekValue] = useState<string>(
(initialSeekSeconds ?? 0).toFixed(2)
);
const containerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const offStart = transport.on("start", () => setState("playing"));
const offStop = transport.on("stop", () => setState("stopped"));
const offPause = transport.on("pause", () => setState("paused"));
return () => {
offStart();
offStop();
offPause();
};
}, [transport]);
const onPlayPause = useCallback(() => {
if (transport.state === "playing") {
pause();
} else {
play();
}
}, [transport, play, pause]);
const onStop = useCallback(() => {
stop();
}, [stop]);
const onSeekChange = useCallback(
(next: string) => {
setSeekValue(next);
const parsed = Number.parseFloat(next);
if (Number.isFinite(parsed) && parsed >= 0) {
seek(parsed);
}
},
[seek]
);
const onTempoChange = useCallback(
(next: number) => {
if (Number.isFinite(next)) {
setBpm(next);
setTempo(next);
}
},
[setTempo]
);
const onLoopToggle = useCallback(() => {
const next = !loop;
transport.loop = next;
setLoopState(next);
}, [loop, transport]);
const handleHotkey = useCallback(
(key: string) => {
if (key === " " || key === "Space" || key === "Spacebar") {
onPlayPause();
return true;
}
if (key === "Escape") {
onStop();
return true;
}
if (key === "l" || key === "L") {
onLoopToggle();
return true;
}
return false;
},
[onPlayPause, onStop, onLoopToggle]
);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLElement>) => {
if (handleHotkey(e.key)) {
e.preventDefault();
}
},
[handleHotkey]
);
useEffect(() => {
if (enableGlobalHotkeys !== true) return undefined;
const handler = (e: KeyboardEvent) => {
if (handleHotkey(e.key)) {
e.preventDefault();
}
};
globalThis.addEventListener("keydown", handler);
return () => globalThis.removeEventListener("keydown", handler);
}, [enableGlobalHotkeys, handleHotkey]);
const lo = minTempo ?? 20;
const hi = maxTempo ?? 300;
return (
<section
ref={containerRef}
className={
className === undefined
? "rax-transport"
: `rax-transport ${className}`
}
data-state={state}
data-loop={loop ? "true" : "false"}
onKeyDown={onKeyDown}
aria-label="Transport controls"
>
<button
type="button"
className="rax-transport__play"
onClick={onPlayPause}
aria-pressed={state === "playing"}
>
{state === "playing" ? "Pause" : "Play"}
</button>
<button
type="button"
className="rax-transport__stop"
onClick={onStop}
>
Stop
</button>
<label className="rax-transport__seek">
<span>Seek</span>
<input
type="number"
min={0}
step={0.01}
value={seekValue}
onChange={(e) => onSeekChange(e.target.value)}
aria-label="Seek position seconds"
/>
</label>
<label className="rax-transport__tempo">
<span>Tempo</span>
<input
type="number"
min={lo}
max={hi}
step={1}
value={bpm}
onChange={(e) =>
onTempoChange(Number.parseFloat(e.target.value))
}
aria-label="Tempo BPM"
/>
</label>
<button
type="button"
className="rax-transport__loop"
onClick={onLoopToggle}
aria-pressed={loop}
>
{loop ? "Loop on" : "Loop off"}
</button>
<span className="rax-transport__position" aria-live="polite">
{seconds.toFixed(2)}s
</span>
</section>
);
}

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,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_CODES,
REACT_AUDIO_ERROR_METADATA,
ReactAudioError,
type ReactAudioErrorCode,
registerReactAudioErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("every code in REACT_AUDIO_CODES has metadata", {
timeout: 5000,
}, () => {
for (const code of Object.values(REACT_AUDIO_CODES)) {
expect(
REACT_AUDIO_ERROR_METADATA[code as ReactAudioErrorCode]
).toBeDefined();
expect(
REACT_AUDIO_ERROR_METADATA[code as ReactAudioErrorCode]
.description.length
).toBeGreaterThan(0);
expect(
REACT_AUDIO_ERROR_METADATA[code as ReactAudioErrorCode].severity
).toMatch(/^(warning|error)$/);
}
expect(Object.keys(REACT_AUDIO_ERROR_METADATA).length).toBe(
Object.values(REACT_AUDIO_CODES).length
);
});
it("ReactAudioError carries code, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new ReactAudioError(
REACT_AUDIO_CODES.UNKNOWN_PARAMETER,
"react-audio.unknown-parameter",
{ context: { paramId: "x" }, cause }
);
expect(err.code).toBe(REACT_AUDIO_CODES.UNKNOWN_PARAMETER);
expect(err.cause).toBe(cause);
expect(err.category).toBe("react-audio");
expect(err.name).toMatch(/^ReactAudioError\((warning|error)\)$/);
});
it("registerReactAudioErrorCodes is idempotent", { timeout: 5000 }, () => {
registerReactAudioErrorCodes();
registerReactAudioErrorCodes();
expect(Object.isFrozen(REACT_AUDIO_ERROR_METADATA)).toBe(true);
});
it("every REACT_AUDIO code is reachable from a call site", {
timeout: 5000,
}, () => {
const thrown = new Set<string>();
const throwBy = (code: ReactAudioErrorCode) => {
try {
throw new ReactAudioError(code, "react-audio.test");
} catch (e) {
if (e instanceof ReactAudioError) thrown.add(e.code as string);
}
};
for (const code of Object.values(REACT_AUDIO_CODES)) {
throwBy(code as ReactAudioErrorCode);
}
for (const code of Object.values(REACT_AUDIO_CODES)) {
expect(thrown.has(code)).toBe(true);
}
});
});

View File

@@ -0,0 +1,103 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error codes published by `@suaveplan/react-audio`. The
* `defineCodes` helper namespaces each entry under the `REACT_AUDIO.`
* prefix; consumers therefore see the dotted codes
* `REACT_AUDIO.NO_PROVIDER`, `REACT_AUDIO.PROVIDER_PENDING`,
* `REACT_AUDIO.UNKNOWN_PARAMETER`, `REACT_AUDIO.PRESET_STORE_FAILED`,
* and `REACT_AUDIO.INVALID_OPTIONS`.
*/
export const REACT_AUDIO_CODES = defineCodes("REACT_AUDIO", [
"NO_PROVIDER",
"PROVIDER_PENDING",
"UNKNOWN_PARAMETER",
"PRESET_STORE_FAILED",
"INVALID_OPTIONS",
"MIDI_CONTROLLER_UNKNOWN",
"MIDI_LEARN_FAILED",
] as const);
export type ReactAudioErrorCode =
(typeof REACT_AUDIO_CODES)[keyof typeof REACT_AUDIO_CODES];
export interface ReactAudioErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
/**
* Frozen registry of metadata keyed by code. Every code in
* `REACT_AUDIO_CODES` has exactly one entry here; the test suite
* cross-checks the two sets.
*/
export const REACT_AUDIO_ERROR_METADATA: Readonly<
Record<ReactAudioErrorCode, ReactAudioErrorMetadata>
> = Object.freeze({
[REACT_AUDIO_CODES.NO_PROVIDER]: {
description:
"a react-audio hook was rendered outside an <AudioProvider> ancestor.",
severity: "error",
},
[REACT_AUDIO_CODES.PROVIDER_PENDING]: {
description:
"an engine-dependent hook was rendered while the provider's async init had not yet resolved; gate on useEngineState() === 'ready'.",
severity: "error",
},
[REACT_AUDIO_CODES.UNKNOWN_PARAMETER]: {
description:
"useParameter received a paramId that is not declared by the plugin's parameterIndex.",
severity: "error",
},
[REACT_AUDIO_CODES.PRESET_STORE_FAILED]: {
description:
"the consumer-supplied preset store rejected a save or load operation.",
severity: "error",
},
[REACT_AUDIO_CODES.INVALID_OPTIONS]: {
description: "AudioProviderOptions failed Zod boundary validation.",
severity: "error",
},
[REACT_AUDIO_CODES.MIDI_CONTROLLER_UNKNOWN]: {
description:
"useMidiController / <MidiControllerSetup> received a controllerId not present in the supplied MidiControllerRegistry.",
severity: "error",
},
[REACT_AUDIO_CODES.MIDI_LEARN_FAILED]: {
description:
"captureNextLearn rejected or no controller was selected when the learn() method on useMidiController fired.",
severity: "error",
},
});
/**
* Base error class for every error thrown by `@suaveplan/react-audio`.
* Every thrown instance carries a code from `REACT_AUDIO_CODES`, a
* `messageKey` suitable for i18n resolution, and an optional
* `context` / `cause`.
*/
export class ReactAudioError extends SuaveplanError<ReactAudioErrorCode> {
public readonly category = "react-audio" as const;
public constructor(
code: ReactAudioErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = `ReactAudioError(${REACT_AUDIO_ERROR_METADATA[code].severity})`;
}
}
/**
* Idempotent registration hook. The metadata is frozen at module load
* and re-exported directly; this function exists for parity with the
* `registerXxxErrorCodes()` convention adopted by sibling audio
* packages and is safe to call any number of times.
*/
export function registerReactAudioErrorCodes(): void {
/* metadata is module-static; no-op. */
}
registerReactAudioErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
ReactAudioErrorCode,
ReactAudioErrorMetadata,
} from "./error-codes.js";
export {
REACT_AUDIO_CODES,
REACT_AUDIO_ERROR_METADATA,
ReactAudioError,
registerReactAudioErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,55 @@
export {
type AnalyserLike,
type UseAnalyserOptions,
type UseAnalyserResult,
useAnalyser,
} from "./use-analyser/index.js";
export { useAudioEngine } from "./use-audio-engine/use-audio-engine.js";
export { useAudioFxPlugin } from "./use-audio-fx-plugin/use-audio-fx-plugin.js";
export { useBus } from "./use-bus/use-bus.js";
export { useChainPlugin } from "./use-chain-plugin/use-chain-plugin.js";
export {
type UseChainSnapshotResult,
useChainSnapshot,
} from "./use-chain-snapshot/use-chain-snapshot.js";
export { useEngineState } from "./use-engine-state/use-engine-state.js";
export {
type LearnTarget,
type MidiControllerRegistry,
MidiControllerRegistryContext,
type UseMidiControllerResult,
useMidiController,
} from "./use-midi-controller/use-midi-controller.js";
export {
type UseParameterResult,
useParameter,
} from "./use-parameter/use-parameter.js";
export {
type PlaylistItem,
type PlaylistRepeat,
type UsePlaylistOptions,
type UsePlaylistResult,
usePlaylist,
} from "./use-playlist/index.js";
export {
type UsePluginChainResult,
usePluginChain,
} from "./use-plugin-chain/use-plugin-chain.js";
export {
type UsePresetResult,
usePreset,
} from "./use-preset/use-preset.js";
export {
useAudioContextOrThrow,
useProviderStateOrThrow,
} from "./use-provider-state.js";
export { useTimeline } from "./use-timeline/use-timeline.js";
export {
type UseTrackResult,
useTrack,
} from "./use-track/use-track.js";
export {
type UseTransportResult,
useTransport,
} from "./use-transport/use-transport.js";
export { useTransportTime } from "./use-transport-time/use-transport-time.js";

View File

@@ -0,0 +1,6 @@
export { useAnalyser } from "./use-analyser.js";
export type {
AnalyserLike,
UseAnalyserOptions,
UseAnalyserResult,
} from "./use-analyser.types.js";

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.

Some files were not shown because too many files have changed in this diff Show More