Initial Commit
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-audio-player - audio-player plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
audio-player plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [audio-player](./src/audio-player/audio-player.md) — `AudioPlayerPlugin` is the universal audio-file player. It decodes a raw `Float32Array` PCM stream from a URL into an i…
|
||||
- [audio-player](./src/audio-player/audio-player.md) — `AudioPlayerPlugin` is the universal audio-file player. It decodes a raw `Float32Array` PCM stream from a URL into an i…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `AudioPlayerPlugin`. These ru…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `AudioPlayerPlugin`. These ru…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./audio-player` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-audio-player",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "audio-player plugin for the @suaveplan/audio-fx-plugin-* family.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "biome check --max-diagnostics 500 src/",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# `audio-player.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `AudioPlayerPlugin`'s construction options, load options, transport state snapshot, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. Consumers can import the types for typed form state, transport-bar state stores, or RPC contracts without pulling in the plugin runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioPlayerLoadOptions` — accepts an `AbortSignal` to cancel an in-flight `load` call.
|
||||
- `AudioPlayerPluginOptions` — `initialVolume`, `initialLoop`, `initialPlaybackRate`; all optional so `new AudioPlayerPlugin()` is valid.
|
||||
- `AudioPlayerState` — frozen transport snapshot: `playing`, `paused`, `currentTimeSec`, `durationSec`, `volume`, `loop`, `playbackRate`.
|
||||
- `AudioPlayerPresetData` — preset payload carrying only the non-parameter `loop` boolean (numeric parameters travel through the universal parameter snapshot).
|
||||
- All fields are `readonly` so consumers can return state directly without defensive cloning.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AudioPlayerPluginOptions,
|
||||
AudioPlayerState,
|
||||
} from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
const opts: AudioPlayerPluginOptions = { initialVolume: 0.7 };
|
||||
function renderTransport(state: AudioPlayerState) {
|
||||
progress.set(state.currentTimeSec / state.durationSec);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AudioPlayerLoadOptions,
|
||||
AudioPlayerState,
|
||||
} from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
class PlaylistController {
|
||||
private ac = new AbortController();
|
||||
|
||||
async load(player: AudioPlayerPlugin, url: string): Promise<void> {
|
||||
this.ac.abort();
|
||||
this.ac = new AbortController();
|
||||
const opts: AudioPlayerLoadOptions = { signal: this.ac.signal };
|
||||
await player.load(url, opts);
|
||||
}
|
||||
|
||||
summarise(state: AudioPlayerState): string {
|
||||
const t = state.currentTimeSec.toFixed(1);
|
||||
const d = state.durationSec.toFixed(1);
|
||||
return `${state.playing ? "▶" : "⏸"} ${t} / ${d}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioPlayerLoadOptions`
|
||||
- `readonly signal?: AbortSignal`
|
||||
- `AudioPlayerPluginOptions`
|
||||
- `readonly initialVolume?: number` (0..1)
|
||||
- `readonly initialLoop?: boolean`
|
||||
- `readonly initialPlaybackRate?: number` (0.25..4)
|
||||
- `AudioPlayerState`
|
||||
- `readonly playing: boolean`
|
||||
- `readonly paused: boolean`
|
||||
- `readonly currentTimeSec: number`
|
||||
- `readonly durationSec: number`
|
||||
- `readonly volume: number`
|
||||
- `readonly loop: boolean`
|
||||
- `readonly playbackRate: number`
|
||||
- `AudioPlayerPresetData`
|
||||
- `readonly loop: boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AudioPlayerState` deliberately includes both `playing` and `paused` even though `playing === true && paused === true` represents the paused-while-active state. Modelling pause as a separate flag matches the `HTMLAudioElement` surface and lets UI code distinguish "stopped" (`!playing`) from "paused" (`playing && paused`) without inferring from `currentTimeSec`. The downside is two booleans for what could be a three-state enum; the upside is direct compatibility with existing transport-bar UI patterns.
|
||||
|
||||
`AudioPlayerPresetData` carries only `loop` because `volume` and `playbackRate` are universal parameters that travel through the parameter snapshot built by `AbstractAudioFxPlugin`. Storing them again in the preset would double-store and risk drift. The buffer itself is never serialised — it lives at a URL the caller owns, so presets reference the URL externally (e.g., in a parent playlist record).
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Options accepted by `AudioPlayerPlugin.load`. */
|
||||
export interface AudioPlayerLoadOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Construction options for `AudioPlayerPlugin`. */
|
||||
export interface AudioPlayerPluginOptions {
|
||||
readonly initialVolume?: number;
|
||||
readonly initialLoop?: boolean;
|
||||
readonly initialPlaybackRate?: number;
|
||||
}
|
||||
|
||||
/** Live state snapshot returned by `getState()`. */
|
||||
export interface AudioPlayerState {
|
||||
readonly playing: boolean;
|
||||
readonly paused: boolean;
|
||||
readonly currentTimeSec: number;
|
||||
readonly durationSec: number;
|
||||
readonly volume: number;
|
||||
readonly loop: boolean;
|
||||
readonly playbackRate: number;
|
||||
}
|
||||
|
||||
/** Preset payload — `volume`/`loop`/`playbackRate` only; buffer is not persisted. */
|
||||
export interface AudioPlayerPresetData {
|
||||
readonly loop: boolean;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# `audio-player`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AudioPlayerPlugin` is the universal audio-file player. It decodes a raw `Float32Array` PCM stream from a URL into an internal buffer and plays it back through the universal `BlockProcessor` path. The plugin is a source (`io.inputs[0].count === 0`, `io.outputs[0].count === 1`) so it slots into chains at position 0 — the chain's IO validator rejects placing it elsewhere.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, runs identically across browser / server / worklet renderers, and exposes the classic media-player surface (`load`, `play`, `pause`, `stop`, `seek`, `setVolume`, `setLoop`, `setPlaybackRate`, `getState`) the way a UI consumer expects. `load` is `AbortSignal`-aware: a pre-aborted signal short-circuits before any fetch.
|
||||
|
||||
## Features
|
||||
|
||||
- Five-method transport: `play` / `pause` / `stop` / `seek(timeSec)` plus the bool-typed `setLoop`.
|
||||
- Three automatable parameters: `volume` (0..1), `loop` (enum), `playbackRate` (0.25..4).
|
||||
- Pitch-preserving playback is **not** included — `setPlaybackRate(2)` reads the buffer at 2× speed, so it doubles both pitch and consumption. For pitch-preserving rate change, chain a `PitchShifterPlugin` downstream.
|
||||
- `load(url, { signal? })` fetches a raw Float32 PCM buffer (same encoding as `SamplerPlugin.loadSample` / `ReverbPlugin.loadImpulseResponse`). Pre-aborted signal throws `AUDIO_FX.ABORTED`.
|
||||
- `play()` before any `load()` throws `AUDIO_FX.SAMPLE_NOT_LOADED` — the same error code the sampler uses for the same misuse.
|
||||
- `setLoop(true)` makes playback wrap back to the buffer start without producing silence in between. Without loop, playback stops cleanly at the end of the buffer and flips `state.playing` to `false`.
|
||||
- `getState()` returns a frozen snapshot of `{ playing, paused, currentTimeSec, durationSec, volume, loop, playbackRate }` so UI code never has to read internal fields.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
const player = new AudioPlayerPlugin();
|
||||
await player.load("/audio/track-01.raw");
|
||||
chain.add(player);
|
||||
player.play();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
const player = new AudioPlayerPlugin({
|
||||
initialVolume: 0.8,
|
||||
initialLoop: true,
|
||||
initialPlaybackRate: 1,
|
||||
});
|
||||
await player.setup(
|
||||
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
const ac = new AbortController();
|
||||
try {
|
||||
await player.load("/audio/loop.raw", { signal: ac.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof AudioFxError && error.code === "AUDIO_FX.ABORTED") {
|
||||
console.warn("audio load aborted");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Drive a UI scrubber from the live state.
|
||||
const ticker = setInterval(() => {
|
||||
const state = player.getState();
|
||||
scrubber.setValue(state.currentTimeSec / state.durationSec);
|
||||
}, 50);
|
||||
|
||||
player.play();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioPlayerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new AudioPlayerPlugin(options?: AudioPlayerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `load(url, { signal? }): Promise<void>` — fetches and registers the playback buffer.
|
||||
- `play(): void` — throws `AUDIO_FX.SAMPLE_NOT_LOADED` if no buffer is loaded.
|
||||
- `pause(): void`, `stop(): void`
|
||||
- `seek(timeSec: number): void` — throws `AUDIO_FX.INVALID_OPTIONS` on negative or non-finite values.
|
||||
- `setVolume(v: 0..1): void`, `setLoop(loop: boolean): void`, `setPlaybackRate(rate: 0.25..4): void`
|
||||
- `getState(): AudioPlayerState`
|
||||
- Inherited accessors for `volume`, `loop`, `playbackRate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The buffer is a `Float32Array` allocated by `load`. Subsequent `load` calls replace the buffer and reset the read position to zero. There is no in-flight load tracking — concurrent `load` calls race; the last one to resolve wins. Most consumers wrap `load` calls in a single `AbortController` and abort the previous call before starting a new one, which is what the AbortSignal handling is designed for.
|
||||
|
||||
`pause` is intentionally a no-op when `playing === false`. Without that guard, a `pause()` before any `play()` would set `paused = true`, which would then make the next `play()` start in a paused state (because `play` only flips `playing`). This matched the behaviour of the main-branch `HTMLAudioElement`-backed implementation and is the principle of least surprise for media-player consumers.
|
||||
|
||||
`setPlaybackRate` validates at the setter rather than relying on the parameter's `min`/`max` clamping. The reason: the parameter declares `automatable: true`, so a parameter-curve automation could in principle write any number; the setter rejects out-of-range *direct* set calls so a UI's invalid input is caught at the boundary. Automation curves operate on a separate ramp and are clamped by the parameter range internally.
|
||||
|
||||
Linear-interpolated read between adjacent samples handles fractional playback rates and seeks. The interpolator is the same shape used by `SamplerPlugin` — `s0 * (1 - frac) + s1 * frac` — so pitch-preservation behaviour is consistent across the two plugins. Looping wraps with `position -= (buffer.length - 1)` rather than `position = 0` so any fractional carry survives the wrap.
|
||||
|
||||
`getState` is the only place that reads the live parameter values without going through `parameterValue` — that helper requires the chain context to compute scheduled automation values. Since `getState` is meant to be safe to call before `setup`, it falls back to `getParameter` (the static-value accessor) instead.
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AudioPlayerPlugin } from "./audio-player.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 1,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "audio-player-test",
|
||||
name: "audio-player-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 128,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
function sampleDataUrl(samples: number[]): string {
|
||||
const arr = Float32Array.from(samples);
|
||||
const b64 = Buffer.from(arr.buffer).toString("base64");
|
||||
return `data:application/octet-stream;base64,${b64}`;
|
||||
}
|
||||
|
||||
describe("AudioPlayerPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
expect(p.metadata.id).toBe("audio-player");
|
||||
expect(p.metadata.category).toBe("source");
|
||||
expect(p.parameters.map((x) => x.id)).toEqual([
|
||||
"volume",
|
||||
"loop",
|
||||
"playbackRate",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new AudioPlayerPlugin({ initialVolume: 99 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: play before load throws AUDIO_FX.SAMPLE_NOT_LOADED", () => {
|
||||
it("calling play() with no buffer rejects with the expected code", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
try {
|
||||
p.play();
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.SAMPLE_NOT_LOADED
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected SAMPLE_NOT_LOADED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: pre-aborted signal short-circuits before fetch", () => {
|
||||
it("aborted signal throws AUDIO_FX.ABORTED", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await p.load("data:application/octet-stream,", {
|
||||
signal: ac.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected AUDIO_FX.ABORTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: load succeeds with a non-aborted signal", () => {
|
||||
it("AbortController.signal that is not aborted lets the load complete", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
const ac = new AbortController();
|
||||
await p.load(sampleDataUrl([0.5, 0.5, 0.5, 0.5]), {
|
||||
signal: ac.signal,
|
||||
});
|
||||
expect(p.getState().durationSec).toBeCloseTo(4 / SAMPLE_RATE, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: load + play renders the loaded buffer on the next process block", () => {
|
||||
it("first samples of the loaded buffer appear on the output, scaled by volume", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin({ initialVolume: 0.5 });
|
||||
await p.load(sampleDataUrl([1, 0.5, 0.25, 0.125]));
|
||||
await p.setup(newConfig(8), newCtx());
|
||||
p.play();
|
||||
const output: Float32Array[] = [new Float32Array(8)];
|
||||
p.process([], output, blockCtx());
|
||||
expect(output[0]![0]).toBeCloseTo(0.5, 5);
|
||||
expect(output[0]![1]).toBeCloseTo(0.25, 5);
|
||||
expect(output[0]![2]).toBeCloseTo(0.125, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: setLoop wraps playback back to the buffer start", () => {
|
||||
it("with loop=true the output cycles through the buffer continuously", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4]));
|
||||
await p.setup(newConfig(16), newCtx());
|
||||
p.setLoop(true);
|
||||
p.play();
|
||||
const output: Float32Array[] = [new Float32Array(16)];
|
||||
p.process([], output, blockCtx());
|
||||
// First three samples ≈ buffer[0..2]; after a wrap the cycle
|
||||
// continues so we see the start of the buffer again later in the
|
||||
// block. No silent gap.
|
||||
expect(output[0]![0]).toBeCloseTo(0.1, 4);
|
||||
let hasZeroGap = false;
|
||||
let nonZeroCount = 0;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (output[0]![i]! === 0) hasZeroGap = true;
|
||||
if (output[0]![i]! !== 0) nonZeroCount++;
|
||||
}
|
||||
expect(hasZeroGap).toBe(false);
|
||||
expect(nonZeroCount).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: non-loop playback ends at buffer end", () => {
|
||||
it("once playback reaches the end, subsequent samples are zero and playing becomes false", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.5, 0.5]));
|
||||
await p.setup(newConfig(16), newCtx());
|
||||
p.play();
|
||||
const output: Float32Array[] = [new Float32Array(16)];
|
||||
p.process([], output, blockCtx());
|
||||
expect(output[0]![15]).toBe(0);
|
||||
expect(p.getState().playing).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: pause + resume keeps position", () => {
|
||||
it("pause halts output; subsequent play() resumes from the saved position", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]));
|
||||
await p.setup(newConfig(4), newCtx());
|
||||
p.play();
|
||||
const output: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], output, blockCtx());
|
||||
p.pause();
|
||||
const pausedOut: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], pausedOut, blockCtx());
|
||||
for (let i = 0; i < 4; i++) expect(pausedOut[0]![i]).toBe(0);
|
||||
p.play();
|
||||
const resumed: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], resumed, blockCtx());
|
||||
// Position was 4 when paused; resumed samples start at buffer[4].
|
||||
expect(resumed[0]![0]).toBeCloseTo(0.5, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: stop resets position to zero", () => {
|
||||
it("after stop() the next play() resumes from the start", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]));
|
||||
await p.setup(newConfig(4), newCtx());
|
||||
p.play();
|
||||
const a: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], a, blockCtx());
|
||||
p.stop();
|
||||
expect(p.getState().currentTimeSec).toBe(0);
|
||||
p.play();
|
||||
const b: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], b, blockCtx());
|
||||
expect(b[0]![0]).toBeCloseTo(0.1, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: seek + setVolume + setPlaybackRate setters", () => {
|
||||
it("seek positions the read pointer at the requested time", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]));
|
||||
await p.setup(newConfig(4), newCtx());
|
||||
p.seek(2 / SAMPLE_RATE);
|
||||
p.play();
|
||||
const out: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], out, blockCtx());
|
||||
expect(out[0]![0]).toBeCloseTo(0.3, 4);
|
||||
});
|
||||
|
||||
it("setPlaybackRate accelerates consumption", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]));
|
||||
await p.setup(newConfig(2), newCtx());
|
||||
p.setPlaybackRate(2);
|
||||
p.play();
|
||||
const out: Float32Array[] = [new Float32Array(2)];
|
||||
p.process([], out, blockCtx());
|
||||
// 2x rate: samples 0 and 2.
|
||||
expect(out[0]![0]).toBeCloseTo(0.1, 4);
|
||||
expect(out[0]![1]).toBeCloseTo(0.3, 4);
|
||||
});
|
||||
|
||||
it("setPlaybackRate rejects out-of-range values", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
try {
|
||||
p.setPlaybackRate(99);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setVolume rejects non-finite values", { timeout: 5_000 }, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
try {
|
||||
p.setVolume(Number.NaN);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setVolume clamps in-range values to [0, 1]", { timeout: 5_000 }, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
p.setVolume(-0.5);
|
||||
expect(p.getState().volume).toBe(0);
|
||||
p.setVolume(1.5);
|
||||
expect(p.getState().volume).toBe(1);
|
||||
p.setVolume(0.7);
|
||||
expect(p.getState().volume).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
|
||||
it("seek rejects negative times", { timeout: 5_000 }, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
try {
|
||||
p.seek(-1);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: getState reports the full transport snapshot", () => {
|
||||
it("after load + play, the snapshot reflects live transport values", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin({ initialLoop: true });
|
||||
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4]));
|
||||
await p.setup(newConfig(2), newCtx());
|
||||
const beforePlay = p.getState();
|
||||
expect(beforePlay.playing).toBe(false);
|
||||
expect(beforePlay.loop).toBe(true);
|
||||
expect(beforePlay.durationSec).toBeCloseTo(4 / SAMPLE_RATE, 6);
|
||||
p.play();
|
||||
const afterPlay = p.getState();
|
||||
expect(afterPlay.playing).toBe(true);
|
||||
expect(afterPlay.paused).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: pause without play is a no-op", () => {
|
||||
it("calling pause() on an inactive plugin does not flip paused to true", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
p.pause();
|
||||
expect(p.getState().paused).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: getState is safe before setup", () => {
|
||||
it("calling getState before setup() falls back to a 48 kHz reference rate", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
const state = p.getState();
|
||||
expect(state.durationSec).toBe(0);
|
||||
expect(state.currentTimeSec).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: seek beyond buffer end clamps to the buffer length", () => {
|
||||
it("seeking past the buffer end positions at the end (next process emits silence)", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.1, 0.2]));
|
||||
await p.setup(newConfig(4), newCtx());
|
||||
p.seek(10); // 10 seconds, well past 2-sample buffer
|
||||
p.play();
|
||||
const out: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], out, blockCtx());
|
||||
// Position was clamped to bufferLen; non-loop playback ends immediately.
|
||||
for (let i = 0; i < 4; i++) expect(out[0]![i]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: preset round-trip", () => {
|
||||
it("loop=true round-trips through getPreset / applyPreset", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioPlayerPlugin({ initialLoop: true });
|
||||
const snap = a.getPreset();
|
||||
const b = new AudioPlayerPlugin();
|
||||
b.applyPreset(snap);
|
||||
expect(b.getState().loop).toBe(true);
|
||||
});
|
||||
|
||||
it("loop=false round-trips through getPreset / applyPreset", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioPlayerPlugin({ initialLoop: false });
|
||||
a.setLoop(false);
|
||||
const snap = a.getPreset();
|
||||
const b = new AudioPlayerPlugin({ initialLoop: true });
|
||||
b.applyPreset(snap);
|
||||
expect(b.getState().loop).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: seek before load clamps to zero", () => {
|
||||
it("seek with no buffer loaded leaves position at zero (bufferLen=0)", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.setup(newConfig(4), newCtx());
|
||||
p.seek(0.5);
|
||||
// No buffer → seek clamps to buffer length 0; play would throw,
|
||||
// so just verify the state snapshot is consistent.
|
||||
expect(p.getState().currentTimeSec).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioPlayerPlugin: reset clears playback state", () => {
|
||||
it("after reset(), getState reports not playing and currentTime=0", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new AudioPlayerPlugin();
|
||||
await p.load(sampleDataUrl([0.5, 0.5, 0.5, 0.5]));
|
||||
await p.setup(newConfig(4), newCtx());
|
||||
p.play();
|
||||
const out: Float32Array[] = [new Float32Array(4)];
|
||||
p.process([], out, blockCtx());
|
||||
p.reset();
|
||||
const s = p.getState();
|
||||
expect(s.playing).toBe(false);
|
||||
expect(s.currentTimeSec).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import type {
|
||||
AudioPlayerLoadOptions,
|
||||
AudioPlayerPluginOptions,
|
||||
AudioPlayerPresetData,
|
||||
AudioPlayerState,
|
||||
} from "../audio-player.types.js";
|
||||
import {
|
||||
AudioPlayerPluginOptionsSchema,
|
||||
AudioPlayerPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
/**
|
||||
* Source plugin that decodes a raw `Float32Array` PCM stream from a
|
||||
* URL into an internal buffer and plays it back through the universal
|
||||
* `BlockProcessor` path. State is a single buffer + fractional read
|
||||
* position; `reset()` clears them.
|
||||
*/
|
||||
export class AudioPlayerPlugin extends AbstractAudioFxPlugin {
|
||||
private buffer: Float32Array | undefined;
|
||||
private position = 0;
|
||||
private playing = false;
|
||||
private paused = false;
|
||||
|
||||
public constructor(options: AudioPlayerPluginOptions = {}) {
|
||||
const parsed = AudioPlayerPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-player-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "audio-player",
|
||||
name: "Audio Player",
|
||||
version: "1.0.0",
|
||||
category: "source",
|
||||
},
|
||||
schema: AudioPlayerPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: 0 }],
|
||||
outputs: [{ count: 1 }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "volume",
|
||||
name: "Volume",
|
||||
value: opts.initialVolume ?? 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 1,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "loop",
|
||||
name: "Loop",
|
||||
value: opts.initialLoop ? 1 : 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
unit: "enum",
|
||||
automatable: false,
|
||||
},
|
||||
{
|
||||
id: "playbackRate",
|
||||
name: "Playback Rate",
|
||||
value: opts.initialPlaybackRate ?? 1,
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
default: 1,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public async load(
|
||||
url: string,
|
||||
opts: AudioPlayerLoadOptions = {}
|
||||
): Promise<void> {
|
||||
if (opts.signal?.aborted) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.ABORTED,
|
||||
"audio-fx.audio-player-load-pre-aborted",
|
||||
{ context: { url } }
|
||||
);
|
||||
}
|
||||
const fetchInit: RequestInit = {};
|
||||
if (opts.signal !== undefined) {
|
||||
fetchInit.signal = opts.signal;
|
||||
}
|
||||
const response = await fetch(url, fetchInit);
|
||||
const bytes = await response.arrayBuffer();
|
||||
this.buffer = new Float32Array(bytes.slice(0));
|
||||
this.position = 0;
|
||||
this.playing = false;
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
public play(): void {
|
||||
if (this.buffer === undefined) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.SAMPLE_NOT_LOADED,
|
||||
"audio-fx.audio-player-play-before-load"
|
||||
);
|
||||
}
|
||||
this.playing = true;
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
if (this.playing) {
|
||||
this.paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.playing = false;
|
||||
this.paused = false;
|
||||
this.position = 0;
|
||||
}
|
||||
|
||||
public seek(timeSec: number): void {
|
||||
if (!Number.isFinite(timeSec) || timeSec < 0) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-player-invalid-seek",
|
||||
{ context: { timeSec } }
|
||||
);
|
||||
}
|
||||
const sr = this.config!.sampleRate;
|
||||
const bufferLen = this.buffer === undefined ? 0 : this.buffer.length;
|
||||
const target = timeSec * sr;
|
||||
this.position = target > bufferLen ? bufferLen : target;
|
||||
}
|
||||
|
||||
public setVolume(volume: number): void {
|
||||
this.setParameter("volume", clamp01(volume));
|
||||
}
|
||||
|
||||
public setLoop(loop: boolean): void {
|
||||
this.setParameter("loop", loop ? 1 : 0);
|
||||
}
|
||||
|
||||
public setPlaybackRate(rate: number): void {
|
||||
if (!Number.isFinite(rate) || rate < 0.25 || rate > 4) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-player-invalid-playback-rate",
|
||||
{ context: { rate } }
|
||||
);
|
||||
}
|
||||
this.setParameter("playbackRate", rate);
|
||||
}
|
||||
|
||||
public getState(): AudioPlayerState {
|
||||
const sr = this.config === undefined ? 48_000 : this.config.sampleRate;
|
||||
const bufferLen = this.buffer === undefined ? 0 : this.buffer.length;
|
||||
return {
|
||||
playing: this.playing,
|
||||
paused: this.paused,
|
||||
currentTimeSec: this.position / sr,
|
||||
durationSec: bufferLen / sr,
|
||||
volume: this.getParameter("volume"),
|
||||
loop: this.getParameter("loop") >= 0.5,
|
||||
playbackRate: this.getParameter("playbackRate"),
|
||||
};
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.position = 0;
|
||||
this.playing = false;
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.position = 0;
|
||||
this.playing = false;
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
_input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const out = output[0]!;
|
||||
const blockSize = out.length;
|
||||
if (this.buffer === undefined || !this.playing || this.paused) {
|
||||
out.fill(0);
|
||||
return;
|
||||
}
|
||||
const buffer = this.buffer;
|
||||
const volume = this.parameterValue("volume");
|
||||
const loop = this.parameterValue("loop") >= 0.5;
|
||||
const rate = this.parameterValue("playbackRate");
|
||||
let position = this.position;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
if (position >= buffer.length - 1) {
|
||||
if (loop) {
|
||||
position = position - (buffer.length - 1);
|
||||
} else {
|
||||
out[i] = 0;
|
||||
this.playing = false;
|
||||
this.position = 0;
|
||||
for (let j = i + 1; j < blockSize; j++) {
|
||||
out[j] = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const i0 = Math.floor(position);
|
||||
const frac = position - i0;
|
||||
const s0 = buffer[i0]!;
|
||||
const s1 = buffer[i0 + 1]!;
|
||||
out[i] = (s0 + (s1 - s0) * frac) * volume;
|
||||
position += rate;
|
||||
}
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
protected override getPresetData(): AudioPlayerPresetData {
|
||||
return { loop: this.getParameter("loop") >= 0.5 };
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
const { loop } = data as AudioPlayerPresetData;
|
||||
this.setParameter("loop", loop ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-player-invalid-volume",
|
||||
{ context: { v } }
|
||||
);
|
||||
}
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AudioPlayerPlugin } from "./audio-player.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export { AudioPlayerPlugin } from "./audio-player/index.js";
|
||||
export type {
|
||||
AudioPlayerLoadOptions,
|
||||
AudioPlayerPluginOptions,
|
||||
AudioPlayerPresetData,
|
||||
AudioPlayerState,
|
||||
} from "./audio-player.types.js";
|
||||
export {
|
||||
AudioPlayerLoadOptionsSchema,
|
||||
AudioPlayerPluginOptionsSchema,
|
||||
AudioPlayerPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AudioPlayerLoadOptionsSchema,
|
||||
AudioPlayerPluginOptionsSchema,
|
||||
AudioPlayerPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# `audio-player/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, load options, and preset payload of `AudioPlayerPlugin`. These run on the public boundary between user code and the plugin: any malformed object hitting the constructor or `applyPreset` is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface (for forms, RPC validation, or storybook controls) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioPlayerPluginOptionsSchema` — validates `initialVolume ∈ [0, 1]`, `initialLoop: boolean`, `initialPlaybackRate ∈ [0.25, 4]`.
|
||||
- `AudioPlayerLoadOptionsSchema` — `.strict()` with `signal: AbortSignal | undefined`.
|
||||
- `AudioPlayerPresetDataSchema` — `.strict()` object carrying only `loop: boolean`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioPlayerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
const parsed = AudioPlayerPluginOptionsSchema.parse({ initialVolume: 0.8 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudioPlayerLoadOptionsSchema,
|
||||
AudioPlayerPluginOptionsSchema,
|
||||
AudioPlayerPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-audio-player";
|
||||
|
||||
function isPlayerPreset(blob: unknown): boolean {
|
||||
return AudioPlayerPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
|
||||
function isLoadOptions(blob: unknown): boolean {
|
||||
return AudioPlayerLoadOptionsSchema.safeParse(blob).success;
|
||||
}
|
||||
|
||||
const fields = AudioPlayerPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) form.addRow(field);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioPlayerPluginOptionsSchema: z.ZodObject<{ initialVolume?, initialLoop?, initialPlaybackRate? }>`
|
||||
- `AudioPlayerLoadOptionsSchema: z.ZodObject<{ signal? }, "strict">`
|
||||
- `AudioPlayerPresetDataSchema: z.ZodObject<{ loop }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AudioPlayerLoadOptionsSchema` uses `z.instanceof(AbortSignal)` for the signal field rather than a duck-typed object check. `instanceof` is the only Zod check that verifies the value is a real `AbortSignal` rather than a structurally-similar object — a fake `{ aborted: false, addEventListener: () => {} }` would pass a duck-typed check but break the actual abort wiring. The constructor-identity check is reliable in every runtime that exposes `AbortSignal` as a global (browser, Node 18+, Bun).
|
||||
|
||||
`AudioPlayerPresetDataSchema` is `.strict()` so unknown fields are rejected — matching the sibling-plugin policy. The plugin's universal parameters (`volume`, `playbackRate`) travel through the parameter snapshot built by `AbstractAudioFxPlugin`, so the preset payload encodes only the non-parameter `loop` flag. Adding more fields here would require coordinating with the base class's preset round-trip; the current minimal payload keeps the preset stable across revisions.
|
||||
@@ -0,0 +1,15 @@
|
||||
import z from "zod";
|
||||
|
||||
export const AudioPlayerPluginOptionsSchema = z.object({
|
||||
initialVolume: z.number().finite().min(0).max(1).optional(),
|
||||
initialLoop: z.boolean().optional(),
|
||||
initialPlaybackRate: z.number().finite().min(0.25).max(4).optional(),
|
||||
});
|
||||
|
||||
export const AudioPlayerLoadOptionsSchema = z
|
||||
.object({ signal: z.instanceof(AbortSignal).optional() })
|
||||
.strict();
|
||||
|
||||
export const AudioPlayerPresetDataSchema = z
|
||||
.object({ loop: z.boolean() })
|
||||
.strict();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-audio-player",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: { index: "src/index.ts" },
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user