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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { AudioPlayerPlugin } from "./audio-player.js";

View File

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

View File

@@ -0,0 +1,5 @@
export {
AudioPlayerLoadOptionsSchema,
AudioPlayerPluginOptionsSchema,
AudioPlayerPresetDataSchema,
} from "./schemas.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });