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-tremolo - tremolo plugin for the @suaveplan/audio-fx-plugin-* family.
tremolo 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
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `TremoloPlugin`. These run at the public bou…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `TremoloPlugin`. These run at the public bou…
- [tremolo](./src/tremolo/tremolo.md) — `TremoloPlugin` is the universal amplitude-modulation tremolo. Its core contract is phase-continuity across block bound…
- [tremolo](./src/tremolo/tremolo.md) — `TremoloPlugin` is the universal amplitude-modulation tremolo. Its core contract is phase-continuity across block bound…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-tremolo";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./tremolo` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-tremolo",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "tremolo 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,11 @@
export {
TremoloPluginOptionsSchema,
TremoloPresetDataSchema,
TremoloWaveformSchema,
} from "./schemas/index.js";
export { TremoloPlugin } from "./tremolo/index.js";
export type {
TremoloPluginOptions,
TremoloPresetData,
TremoloWaveform,
} from "./tremolo.types.js";

View File

@@ -0,0 +1,5 @@
export {
TremoloPluginOptionsSchema,
TremoloPresetDataSchema,
TremoloWaveformSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,66 @@
# `tremolo/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options and preset payload of `TremoloPlugin`. These run at the public boundary so any malformed object — wrong waveform name, out-of-range rate, NaN depth — is rejected with `AUDIO_FX.INVALID_OPTIONS` or `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
- `TremoloWaveformSchema` — `z.enum(["sine", "triangle", "square"])` — used in both options and preset payloads.
- `TremoloPluginOptionsSchema` — validates the three optional construction fields with their documented ranges (`rate` 0.05..20 Hz, `depth` 0..1, `waveform` enum).
- `TremoloPresetDataSchema` — `.strict()` object carrying only `waveform`; extra fields are rejected.
- All numeric fields are `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
## Usage: Basic Example
```ts
import { TremoloPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-tremolo";
const parsed = TremoloPluginOptionsSchema.parse({
initialWaveform: "triangle",
});
```
## Usage: Advanced Example
```ts
import {
TremoloPresetDataSchema,
TremoloWaveformSchema,
} from "@suaveplan/audio-fx-plugin-tremolo";
// Validate a preset blob coming off the wire before applying it.
function safeApplyPreset(plugin: TremoloPlugin, payload: unknown) {
const parsed = TremoloPresetDataSchema.safeParse(payload);
if (!parsed.success) {
log.warn("dropped bad tremolo preset", parsed.error.issues);
return;
}
plugin.applyPreset({
version: 1,
parameters: {},
presetData: parsed.data,
});
}
const waveforms = TremoloWaveformSchema.options;
console.log(waveforms); // ["sine", "triangle", "square"]
```
## API Reference
- `TremoloWaveformSchema: z.ZodEnum<["sine", "triangle", "square"]>`
- `TremoloPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepth?, initialWaveform? }>`
- `TremoloPresetDataSchema: z.ZodObject<{ waveform: TremoloWaveform }, "strict">`
## Implementation Notes
`TremoloWaveformSchema` is exported as a named schema (not inlined) so the same value is reused by `TremoloPluginOptionsSchema.initialWaveform` and `TremoloPresetDataSchema.waveform`. That guarantees both validation paths agree byte-for-byte on the legal waveform set — adding a fourth waveform in a future revision is a one-line change in `schemas.ts`.
`TremoloPresetDataSchema` uses `.strict()` for the same reason as the other plugins: unknown fields almost always indicate a payload from a sibling plugin or a forwards-incompatible schema. Silent rejection at the boundary is much less harmful than partial application, which can leave the plugin in a half-restored state. The combined "waveform-must-be-known, payload-must-be-exact" stance keeps the preset surface stable across plugin versions.

View File

@@ -0,0 +1,13 @@
import z from "zod";
export const TremoloWaveformSchema = z.enum(["sine", "triangle", "square"]);
export const TremoloPluginOptionsSchema = z.object({
initialRateHz: z.number().finite().min(0.05).max(20).optional(),
initialDepth: z.number().finite().min(0).max(1).optional(),
initialWaveform: TremoloWaveformSchema.optional(),
});
export const TremoloPresetDataSchema = z
.object({ waveform: TremoloWaveformSchema })
.strict();

View File

@@ -0,0 +1,64 @@
# `tremolo.types`
## Purpose
TypeScript-only type definitions for `TremoloPlugin`'s construction options, waveform enum, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
The `TremoloWaveform` union is the canonical naming of the three LFO shapes; saved presets travel by name (not index) so a future parameter reordering does not break stored data.
## Features
- `TremoloWaveform` — textual enum (`"sine" | "triangle" | "square"`) used in options and presets.
- `TremoloPluginOptions` — all-optional construction options; the default constructor (`new TremoloPlugin()`) is valid.
- `TremoloPresetData` — preset payload carrying only the non-numeric `waveform`; numeric parameters travel through the universal parameter snapshot.
- `readonly` fields prevent accidental mutation of saved options.
## Usage: Basic Example
```ts
import type {
TremoloPluginOptions,
TremoloWaveform,
} from "@suaveplan/audio-fx-plugin-tremolo";
const opts: TremoloPluginOptions = { initialWaveform: "triangle" };
const wf: TremoloWaveform = opts.initialWaveform ?? "sine";
```
## Usage: Advanced Example
```ts
import type {
TremoloPluginOptions,
TremoloWaveform,
} from "@suaveplan/audio-fx-plugin-tremolo";
const waveforms: readonly TremoloWaveform[] = ["sine", "triangle", "square"];
function nextWaveform(current: TremoloWaveform): TremoloWaveform {
const i = waveforms.indexOf(current);
return waveforms[(i + 1) % waveforms.length]!;
}
const opts: TremoloPluginOptions = {
initialRateHz: 6,
initialDepth: 0.8,
initialWaveform: nextWaveform("sine"),
};
```
## API Reference
- `TremoloWaveform = "sine" | "triangle" | "square"`
- `TremoloPluginOptions`
- `readonly initialRateHz?: number`
- `readonly initialDepth?: number`
- `readonly initialWaveform?: TremoloWaveform`
- `TremoloPresetData`
- `readonly waveform: TremoloWaveform`
## Implementation Notes
The `waveform` plugin parameter is a numeric enum (0..2) for compatibility with the universal `Parameter` shape, which carries `number` values. The plugin maps that index to/from the textual `TremoloWaveform` enum at the boundaries (`getPresetData` / `applyPresetData`). This keeps the runtime fast (no string comparisons in the inner loop) while making saved presets human-readable and forward-compatible.
`TremoloPresetData` deliberately includes only `waveform`. Every numeric parameter (`rate`, `depth`) is already part of the universal parameter snapshot that `AbstractAudioFxPlugin` builds, so re-encoding them here would double-store data and risk drift. The base class honours both the universal parameters and the plugin-specific `presetData` field, with plugin-specific decoding applied after universal restoration.

View File

@@ -0,0 +1,14 @@
/** Selectable LFO waveforms for `TremoloPlugin`. */
export type TremoloWaveform = "sine" | "triangle" | "square";
/** Construction options for `TremoloPlugin`. */
export interface TremoloPluginOptions {
readonly initialRateHz?: number;
readonly initialDepth?: number;
readonly initialWaveform?: TremoloWaveform;
}
/** Persisted preset payload (waveform is non-numeric, stored by name). */
export interface TremoloPresetData {
readonly waveform: TremoloWaveform;
}

View File

@@ -0,0 +1 @@
export { TremoloPlugin } from "./tremolo.js";

View File

@@ -0,0 +1,72 @@
# `tremolo`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TremoloPlugin` is the universal amplitude-modulation tremolo. Its core contract is phase-continuity across block boundaries: the LFO phase is derived purely from `BlockContext.blockOffsetSamples`, never from an internal counter, so splitting a render into smaller blocks (or merging into a larger block) produces sample-identical output. This makes the plugin safe to use across variable-block-size renderers — browser AudioWorklet's 128-sample blocks, server-side 4096-sample blocks, offline mastering at one block per second — without audible artefacts at block boundaries.
The plugin extends `AbstractAudioFxPlugin` and is stateless on the processing path. `reset()` is a no-op by design.
## Features
- Three parameters: `rate` (Hz, 0.05..20), `depth` (0..1), `waveform` (enum `sine | triangle | square`).
- Three LFO waveforms cover the most common tremolo timbres: smooth (sine), classic (triangle), choppy (square).
- `depth === 0` early-out: returns the dry path sample-for-sample with no allocation, satisfying the spec contract "zero depth leaves the signal unmodulated".
- Phase derived from `ctx.blockOffsetSamples + i` per sample, so the same span rendered as one large block or many small blocks produces identical output. This is the contract in the spec test "LFO is phase-continuous and block-size independent".
- Stateless: the processing function has no buffer, no running phase, no per-channel state. The plugin survives `reset()`, hot reload, or renderer migration without artefacts.
## Usage: Basic Example
```ts
import { TremoloPlugin } from "@suaveplan/audio-fx-plugin-tremolo";
const tremolo = new TremoloPlugin({
initialRateHz: 5,
initialDepth: 0.5,
initialWaveform: "sine",
});
chain.add(tremolo);
```
## Usage: Advanced Example
```ts
import { TremoloPlugin } from "@suaveplan/audio-fx-plugin-tremolo";
// Vintage "drum-machine" tremolo: square wave, fast rate, hard depth.
// Useful for stutter-gate riser effects on drums and synths.
const chop = new TremoloPlugin({
initialRateHz: 8,
initialDepth: 1,
initialWaveform: "square",
});
await chop.setup(
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
ctx
);
// Automate rate from 4 Hz to 16 Hz for a build-up effect.
chop.setParameterAt("rate", 4, 0);
chop.setParameterAt("rate", 16, 4);
```
## API Reference
- `TremoloPlugin` (class extends `AbstractAudioFxPlugin`)
- `new TremoloPlugin(options?: TremoloPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- Inherited parameter accessors for `rate`, `depth`, `waveform`.
- Types: `TremoloWaveform = "sine" | "triangle" | "square"`.
- Schemas: `TremoloPluginOptionsSchema`, `TremoloPresetDataSchema`, `TremoloWaveformSchema`.
## Implementation Notes
The gain transfer is `gain = 1 − depth + depth · (lfo + 1) / 2`, where `lfo ∈ [-1, 1]`. At `depth = 0`, this collapses to `gain = 1` exactly; at `depth = 1`, it spans `[0, 1]`. The choice of `(lfo + 1) / 2` (range `[0, 1]`) over `lfo` (range `[-1, 1]`) means the LFO crest produces unity gain and the trough produces `1 − depth` — never inverting the signal. Inverting would be useful for "ring-modulation"-style effects but is not the spec's expectation of tremolo.
The triangle waveform is implemented via `(2/π) · asin(sin(phase))`. This expression is mathematically a triangle of amplitude 1 and the same fundamental frequency as `sin(phase)`. It uses only library math, has no branches in the inner loop, and stays smoothly differentiable around its peaks — the absence of branches matters when the LFO is automated and a branch-based piecewise triangle would predict-miss on every modulation cycle.
The square waveform is `Math.sin(phase) >= 0 ? 1 : -1`, which collapses to a clean two-level signal. The branch is unavoidable here but executes at the LFO rate (not the sample rate of the underlying signal) so its cost is negligible.
`depth === 0` triggers an explicit early-out (a `Float32Array.set` copy per channel) rather than relying on the formula collapsing to `gain = 1`. Even though the formula is exact at `depth = 0` in IEEE arithmetic, the early-out short-circuits the entire DSP loop including the LFO eval, which is a meaningful saving when the plugin is bypassed by automation rather than removed from the chain.

View File

@@ -0,0 +1,221 @@
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 { TremoloPlugin } from "./tremolo.js";
const SAMPLE_RATE = 48_000;
function blockCtx(blockOffsetSamples = 0): BlockContext {
return {
blockOffsetSamples,
sampleRate: SAMPLE_RATE,
channels: 1,
opts: {},
};
}
function buffers(
size: number,
channels = 1
): { input: Float32Array[]; output: Float32Array[] } {
const input: Float32Array[] = [];
const output: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
input.push(new Float32Array(size));
output.push(new Float32Array(size));
}
return { input, output };
}
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels, blockSize };
}
function newCtx(channels = 1): ChainContext {
const chain: ChainHandle = {
id: "tremolo-test",
name: "tremolo-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 256,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
describe("TremoloPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const t = new TremoloPlugin();
expect(t.metadata.id).toBe("tremolo");
expect(t.metadata.category).toBe("modulation");
expect(t.parameters.map((p) => p.id)).toEqual([
"rate",
"depth",
"waveform",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new TremoloPlugin({ initialDepth: 5 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("TremoloPlugin: zero depth leaves the signal unmodulated", () => {
it("output equals input sample-for-sample at depth=0", {
timeout: 5_000,
}, async () => {
const t = new TremoloPlugin({ initialDepth: 0 });
await t.setup(newConfig(1024, 1), newCtx(1));
const { input, output } = buffers(1024, 1);
for (let i = 0; i < 1024; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
t.process(input, output, blockCtx());
for (let i = 0; i < 1024; i++) {
expect(output[0]![i]).toBe(input[0]![i]!);
}
});
});
describe("TremoloPlugin: LFO is phase-continuous and block-size independent", () => {
it("rendering 1024 in one block === rendering 512+512 with blockOffsetSamples", {
timeout: 5_000,
}, async () => {
const single = new TremoloPlugin({
initialRateHz: 3,
initialDepth: 0.7,
initialWaveform: "sine",
});
const split = new TremoloPlugin({
initialRateHz: 3,
initialDepth: 0.7,
initialWaveform: "sine",
});
await single.setup(newConfig(1024, 1), newCtx(1));
await split.setup(newConfig(512, 1), newCtx(1));
const inputA = new Float32Array(1024);
for (let i = 0; i < 1024; i++) inputA[i] = 0.5;
const outA = new Float32Array(1024);
single.process([inputA], [outA], blockCtx(0));
const outB1 = new Float32Array(512);
const outB2 = new Float32Array(512);
split.process([inputA.subarray(0, 512)], [outB1], blockCtx(0));
split.process([inputA.subarray(512, 1024)], [outB2], blockCtx(512));
for (let i = 0; i < 512; i++) {
expect(outA[i]).toBeCloseTo(outB1[i]!, 6);
expect(outA[i + 512]).toBeCloseTo(outB2[i]!, 6);
}
});
});
describe("TremoloPlugin: triangle waveform output is bounded and varies", () => {
it("triangle output stays within [0, 0.5] and modulates the signal", {
timeout: 5_000,
}, async () => {
const t = new TremoloPlugin({
initialRateHz: 20,
initialDepth: 1,
initialWaveform: "triangle",
});
const span = SAMPLE_RATE / 4; // 0.25 sec, 5 cycles at 20 Hz.
await t.setup(newConfig(span, 1), newCtx(1));
const { input, output } = buffers(span, 1);
for (let i = 0; i < span; i++) input[0]![i] = 0.5;
t.process(input, output, blockCtx(0));
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (let i = 0; i < span; i++) {
const v = output[0]![i]!;
if (v < min) min = v;
if (v > max) max = v;
}
expect(min).toBeGreaterThanOrEqual(0);
expect(max).toBeLessThanOrEqual(0.5);
expect(max - min).toBeGreaterThan(0.4);
});
});
describe("TremoloPlugin: square waveform alternates between two gains", () => {
it("square output takes both gain values across a full LFO cycle", {
timeout: 5_000,
}, async () => {
const t = new TremoloPlugin({
initialRateHz: 20,
initialDepth: 1,
initialWaveform: "square",
});
const span = SAMPLE_RATE / 4; // 0.25 sec, 5 cycles at 20 Hz.
await t.setup(newConfig(span, 1), newCtx(1));
const { input, output } = buffers(span, 1);
for (let i = 0; i < span; i++) input[0]![i] = 0.5;
t.process(input, output, blockCtx(0));
const unique = new Set<number>();
for (let i = 0; i < span; i++) {
unique.add(Math.round(output[0]![i]! * 1_000) / 1_000);
}
// Exactly two values: 0 (lfo=-1, gain=0) and 0.5 (lfo=1, gain=1).
expect(unique.size).toBe(2);
expect(unique.has(0)).toBe(true);
expect(unique.has(0.5)).toBe(true);
});
});
describe("TremoloPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips waveform", {
timeout: 5_000,
}, () => {
const a = new TremoloPlugin({ initialWaveform: "triangle" });
const snap = a.getPreset();
const b = new TremoloPlugin();
b.applyPreset(snap);
expect(b.getParameter("waveform")).toBe(1);
});
it("square preset round-trips", { timeout: 5_000 }, () => {
const a = new TremoloPlugin({ initialWaveform: "square" });
const snap = a.getPreset();
const b = new TremoloPlugin();
b.applyPreset(snap);
expect(b.getParameter("waveform")).toBe(2);
});
});
describe("TremoloPlugin: reset is a no-op", () => {
it("does not throw and state stays stateless", {
timeout: 5_000,
}, async () => {
const t = new TremoloPlugin();
await t.setup(newConfig(64, 1), newCtx(1));
expect(() => t.reset()).not.toThrow();
});
});

View File

@@ -0,0 +1,150 @@
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 {
TremoloPluginOptionsSchema,
TremoloPresetDataSchema,
} from "../schemas/index.js";
import type {
TremoloPluginOptions,
TremoloPresetData,
TremoloWaveform,
} from "../tremolo.types.js";
const WAVEFORMS: readonly TremoloWaveform[] = ["sine", "triangle", "square"];
const TWO_PI = 2 * Math.PI;
/**
* Amplitude-modulation tremolo. The LFO is phase-continuous across
* block boundaries by construction: its phase is derived purely from
* `BlockContext.blockOffsetSamples`, not from any internal counter,
* so splitting a render into two smaller blocks produces sample-
* identical output. `depth === 0` is bit-transparent to the dry path.
*/
export class TremoloPlugin extends AbstractAudioFxPlugin {
public constructor(options: TremoloPluginOptions = {}) {
const parsed = TremoloPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.tremolo-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
const initialWaveformIdx = WAVEFORMS.indexOf(
opts.initialWaveform ?? "sine"
);
super({
metadata: {
id: "tremolo",
name: "Tremolo",
version: "1.0.0",
category: "modulation",
},
schema: TremoloPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [
{
id: "rate",
name: "Rate",
value: opts.initialRateHz ?? 5,
min: 0.05,
max: 20,
default: 5,
unit: "hz",
automatable: true,
},
{
id: "depth",
name: "Depth",
value: opts.initialDepth ?? 0.5,
min: 0,
max: 1,
default: 0.5,
unit: "percent",
automatable: true,
},
{
id: "waveform",
name: "Waveform",
value: initialWaveformIdx,
min: 0,
max: WAVEFORMS.length - 1,
default: 0,
unit: "enum",
automatable: false,
},
],
supportedSchemaVersion: 1,
});
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
/* Stateless — phase derives from BlockContext.blockOffsetSamples. */
}
protected override resetState(): void {
/* Nothing to reset. */
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
const depth = this.parameterValue("depth");
const blockSize = input[0]!.length;
if (depth === 0) {
for (let ch = 0; ch < input.length; ch++) {
output[ch]!.set(input[ch]!);
}
return;
}
const rate = this.parameterValue("rate");
const waveformIdx = Math.round(this.parameterValue("waveform"));
const waveform = WAVEFORMS[waveformIdx]!;
const sr = ctx.sampleRate;
const phaseStep = (TWO_PI * rate) / sr;
const offset = ctx.blockOffsetSamples;
for (let i = 0; i < blockSize; i++) {
const phase = phaseStep * (offset + i);
const lfo = evaluateWaveform(waveform, phase);
const gain = 1 - depth + depth * (lfo + 1) * 0.5;
for (let ch = 0; ch < input.length; ch++) {
output[ch]![i] = input[ch]![i]! * gain;
}
}
}
protected override getPresetData(): TremoloPresetData {
const idx = Math.round(this.getParameter("waveform"));
return { waveform: WAVEFORMS[idx]! };
}
protected override applyPresetData(data: unknown): void {
const { waveform } = data as TremoloPresetData;
this.setParameter("waveform", WAVEFORMS.indexOf(waveform));
}
}
function evaluateWaveform(waveform: TremoloWaveform, phase: number): number {
if (waveform === "sine") {
return Math.sin(phase);
}
if (waveform === "triangle") {
return (2 / Math.PI) * Math.asin(Math.sin(phase));
}
return Math.sin(phase) >= 0 ? 1 : -1;
}

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