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-pitch-shifter - pitch-shifter plugin for the @suaveplan/audio-fx-plugin-* family.
pitch-shifter 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
- [pitch-shifter](./src/pitch-shifter/pitch-shifter.md) — `PitchShifterPlugin` is the universal granular pitch shifter. It writes incoming audio into a per-channel circular ring…
- [pitch-shifter](./src/pitch-shifter/pitch-shifter.md) — `PitchShifterPlugin` is the universal granular pitch shifter. It writes incoming audio into a per-channel circular ring…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `PitchShifterPlugin`. These run on the publi…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `PitchShifterPlugin`. These run on the publi…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-pitch-shifter";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./pitch-shifter` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-pitch-shifter",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "pitch-shifter 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,6 @@
export { PitchShifterPlugin } from "./pitch-shifter/index.js";
export type { PitchShifterPluginOptions } from "./pitch-shifter.types.js";
export {
PitchShifterPluginOptionsSchema,
PitchShifterPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,49 @@
# `pitch-shifter.types`
## Purpose
TypeScript-only type definitions for `PitchShifterPlugin`'s construction options. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. Consumers can import the type for typed form state, dependency-injection wiring, or storybook controls without pulling in the plugin runtime.
## Features
- `PitchShifterPluginOptions` — both fields optional so the default constructor (`new PitchShifterPlugin()`) is valid.
- `readonly` properties — emphasises that the options object is consumed once at construction.
- `windowSize` accepts one of `512 | 1024 | 2048 | 4096`; the validation lives in the Zod schema rather than in the TypeScript type to avoid forcing a literal-union cast at every call site.
## Usage: Basic Example
```ts
import type { PitchShifterPluginOptions } from "@suaveplan/audio-fx-plugin-pitch-shifter";
const opts: PitchShifterPluginOptions = { initialSemitones: 5 };
```
## Usage: Advanced Example
```ts
import type { PitchShifterPluginOptions } from "@suaveplan/audio-fx-plugin-pitch-shifter";
const presets: Record<string, PitchShifterPluginOptions> = {
fast: { windowSize: 512 },
balanced: { windowSize: 2048 },
fidelity: { windowSize: 4096 },
};
function pickWindow(latencyBudgetSamples: number): PitchShifterPluginOptions {
if (latencyBudgetSamples >= 2048) return presets.fidelity!;
if (latencyBudgetSamples >= 1024) return presets.balanced!;
return presets.fast!;
}
```
## API Reference
- `PitchShifterPluginOptions`
- `readonly initialSemitones?: number` — initial pitch offset in semitones (-24..24).
- `readonly windowSize?: number` — analysis/synthesis window, one of 512/1024/2048/4096 (default 2048).
## Implementation Notes
The window size is constrained to a small set of powers of two. The implementation does not technically require a power of two (the ring buffer indexes by modulo, not bitmask), but constraining the choice makes the plugin behaviourally portable: a future FFT-based revision (phase vocoder) would benefit from power-of-two window sizes for efficient FFT routines, and locking the choice here means a swap is non-breaking. The four sizes cover the spectrum from "low latency, low fidelity" (512 = ~10 ms at 48 kHz) to "high fidelity, high latency" (4096 = ~85 ms).
`initialSemitones` is bounded at ±24 (two octaves). Beyond this, the granular algorithm's wrap rate exceeds the perceptual fusion frequency and the output starts to sound like granular synthesis rather than a clean shift. Callers wanting extreme transposition should chain multiple instances or wait for the phase-vocoder variant.

View File

@@ -0,0 +1,5 @@
/** Construction options for `PitchShifterPlugin`. */
export interface PitchShifterPluginOptions {
readonly initialSemitones?: number;
readonly windowSize?: number;
}

View File

@@ -0,0 +1 @@
export { PitchShifterPlugin } from "./pitch-shifter.js";

View File

@@ -0,0 +1,66 @@
# `pitch-shifter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PitchShifterPlugin` is the universal granular pitch shifter. It writes incoming audio into a per-channel circular ring buffer and reads from two overlapping pointers advancing at the pitch ratio `2^(semitones / 12)`. The two read streams are Hann-window crossfaded into a single continuous output of the same length as the input — duration is preserved while pitch is shifted. The plugin maintains its overlap-add state across blocks; `reset()` clears the ring and re-seeds the read pointers in place.
A browser-native fast path is available via `getWorkletProcessor()`, which returns a self-contained `AudioWorkletProcessor` source string for off-thread rendering. The worklet path is functionally equivalent to the universal block path so a chain that uses one renders identically to a chain that uses the other.
## Features
- One automatable parameter: `semitones` (-24..24, default 0).
- Configurable window size at construction (`512`, `1024`, `2048`, `4096`; default `2048`).
- Stateful: per-channel ring buffer, two fractional read pointers, write head. Survives block boundaries; `reset()` clears all of it.
- `latencySamples = windowSize / 2` declared in `capabilities`. Chain consumers can read this to align downstream processing.
- `getWorkletProcessor()` returns a self-contained string containing a `registerProcessor("suaveplan-pitch-shifter", ...)` call and no Node-only `require` / ESM `import` — the spec's "worklet processor source is a self-contained string" contract.
## Usage: Basic Example
```ts
import { PitchShifterPlugin } from "@suaveplan/audio-fx-plugin-pitch-shifter";
const shifter = new PitchShifterPlugin({ initialSemitones: 7 });
chain.add(shifter);
```
## Usage: Advanced Example
```ts
import { PitchShifterPlugin } from "@suaveplan/audio-fx-plugin-pitch-shifter";
// Browser fast path: mount the worklet processor and forward the
// `semitones` automation channel.
const shifter = new PitchShifterPlugin({
initialSemitones: 0,
windowSize: 4096,
});
const source = shifter.getWorkletProcessor();
await context.audioWorklet.addModule(URL.createObjectURL(new Blob([source])));
const node = new AudioWorkletNode(context, "suaveplan-pitch-shifter", {
processorOptions: { windowSize: 4096 },
});
node.parameters.get("semitones")!.setValueAtTime(-5, 0);
```
## API Reference
- `PitchShifterPlugin` (class extends `AbstractAudioFxPlugin`)
- `new PitchShifterPlugin(options?: PitchShifterPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options (semitones outside ±24, non-power-of-two window size).
- `getWorkletProcessor(): string` — returns the self-contained AudioWorkletProcessor source string.
- Inherited parameter accessor for `semitones`.
## Implementation Notes
The algorithm is a simple two-grain SOLA (synchronous overlap-add) variant: each output sample mixes two read-pointer reads weighted by a Hann window of the pointer's distance to the write head. Read pointer 1 starts half a window behind the write head; read pointer 2 starts a full window behind. As the pointers advance at the pitch ratio while the write head advances at the input rate, the lag between write and read changes — when the lag escapes `[0, windowSize]`, the pointer wraps by `±windowSize` so it stays in the analysis band. The Hann weighting ensures the wrap discontinuity lands at the trough of the crossfade, where its perceptual impact is minimal.
This is not a true phase vocoder. A phase vocoder analyses the input in the frequency domain via FFT, computes per-bin instantaneous frequencies, and resynthesises with adjusted phase advances — that produces higher-fidelity shifts for polyphonic / wideband material but requires a much larger code surface (FFT + magnitude/phase analysis + synthesis loop). The two-grain SOLA picks the cheaper algorithm for the MVP; a future revision can add a `PhaseVocoderPitchShifter` on a sibling subpath without breaking this contract. The spec's scenarios — "zero-semitone shift preserves pitch and duration within phase-vocoder reconstruction tolerance" and "octave-up shift doubles the fundamental" — are both within reach for SOLA on steady tones, with tolerances loose enough to absorb the wrap artefacts.
The ring buffer is sized at `windowSize × channels` and indexed by `channelOffset + (pos mod windowSize)`. Flattening avoids one indirection per inner-loop sample and keeps the ring contiguous in memory for cache locality. The two read pointers carry fractional positions so the linear-interp read produces smooth output even at non-integer pitch ratios.
`getWorkletProcessor()` returns a template string that includes the full processor class. We do not minify or precompile it; the worklet engine parses it on `addModule`. The string is constructed without any module-system primitives (`require`, `import`) so it can be served as a `Blob` URL without an extra build step — verified by the test "getWorkletProcessor returns self-contained processor source".
Latency is declared as `windowSize / 2`. The Hann weight is zero at the write head and at one window behind, peaking at half a window — so the first meaningful output sample appears after the read pointer reaches the centre of the buffer, which by construction happens after `windowSize / 2` input samples. Consumers can read `capabilities.latencySamples` and compensate (e.g., by delaying parallel dry signal in a wet/dry mixer downstream).

View File

@@ -0,0 +1,273 @@
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 { PitchShifterPlugin } from "./pitch-shifter.js";
const SAMPLE_RATE = 48_000;
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
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: "pitch-shifter-test",
name: "pitch-shifter-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 256,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function dominantFreqHz(buf: Float32Array, sampleRate: number): number {
let crossings = 0;
for (let i = 1; i < buf.length; i++) {
if (
(buf[i - 1]! < 0 && buf[i]! >= 0) ||
(buf[i - 1]! >= 0 && buf[i]! < 0)
) {
crossings++;
}
}
return (crossings / 2) * (sampleRate / buf.length);
}
describe("PitchShifterPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const p = new PitchShifterPlugin();
expect(p.metadata.id).toBe("pitch-shifter");
expect(p.metadata.category).toBe("modulation");
expect(p.parameters.map((x) => x.id)).toEqual(["semitones"]);
expect(p.capabilities.hasWorklet).toBe(true);
expect(p.capabilities.latencySamples).toBeGreaterThan(0);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new PitchShifterPlugin({ initialSemitones: 99 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("rejects non-power-of-two windowSize", { timeout: 5_000 }, () => {
try {
new PitchShifterPlugin({ windowSize: 1000 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("PitchShifterPlugin: zero-semitone shift preserves pitch and duration", () => {
it("440 Hz sine remains ≈ 440 Hz at semitones=0", {
timeout: 5_000,
}, async () => {
const p = new PitchShifterPlugin({ initialSemitones: 0 });
await p.setup(newConfig(SAMPLE_RATE), newCtx());
const { input, output } = buffers(SAMPLE_RATE);
for (let i = 0; i < SAMPLE_RATE; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
p.process(input, output, blockCtx());
// Skip the leading window-size latency before measuring.
const tail = output[0]!.subarray(SAMPLE_RATE / 2);
const freq = dominantFreqHz(tail, SAMPLE_RATE);
expect(Math.abs(freq - 440)).toBeLessThan(30);
expect(output[0]!.length).toBe(input[0]!.length);
});
});
describe("PitchShifterPlugin: twelve-semitone shift doubles the fundamental without changing duration", () => {
it("440 Hz sine becomes ≈ 880 Hz at semitones=12", {
timeout: 5_000,
}, async () => {
const p = new PitchShifterPlugin({ initialSemitones: 12 });
await p.setup(newConfig(SAMPLE_RATE), newCtx());
const { input, output } = buffers(SAMPLE_RATE);
for (let i = 0; i < SAMPLE_RATE; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
p.process(input, output, blockCtx());
// Skip leading latency before measuring; the tail must be the
// pitch-shifted steady state.
const tail = output[0]!.subarray(SAMPLE_RATE / 2);
const freq = dominantFreqHz(tail, SAMPLE_RATE);
expect(Math.abs(freq - 880)).toBeLessThan(60);
expect(output[0]!.length).toBe(input[0]!.length);
});
});
describe("PitchShifterPlugin: getWorkletProcessor returns self-contained processor source", () => {
it("returns a non-empty string containing registerProcessor", {
timeout: 5_000,
}, () => {
const p = new PitchShifterPlugin();
const src = p.getWorkletProcessor();
expect(src.length).toBeGreaterThan(0);
expect(src).toContain("registerProcessor");
expect(src).toContain("AudioWorkletProcessor");
expect(src).not.toContain("require(");
expect(src).not.toContain("import ");
});
});
describe("PitchShifterPlugin: negative-semitone shift halves the fundamental", () => {
it("440 Hz sine becomes ≈ 220 Hz at semitones=-12", {
timeout: 5_000,
}, async () => {
const p = new PitchShifterPlugin({ initialSemitones: -12 });
await p.setup(newConfig(SAMPLE_RATE), newCtx());
const { input, output } = buffers(SAMPLE_RATE);
for (let i = 0; i < SAMPLE_RATE; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
p.process(input, output, blockCtx());
// Tail after the initial latency; pitch down → readPos falls behind
// and wraps forward (the `lag > W` branch).
const tail = output[0]!.subarray(SAMPLE_RATE / 2);
const freq = dominantFreqHz(tail, SAMPLE_RATE);
expect(Math.abs(freq - 220)).toBeLessThan(40);
});
});
describe("PitchShifterPlugin: maintains state across block boundaries", () => {
it("processing the same 2N samples as one block vs two N-sized blocks yields similar tails", {
timeout: 5_000,
}, async () => {
const big = new PitchShifterPlugin({ initialSemitones: 7 });
const split = new PitchShifterPlugin({ initialSemitones: 7 });
await big.setup(newConfig(SAMPLE_RATE), newCtx());
await split.setup(newConfig(SAMPLE_RATE / 2), newCtx());
const input = new Float32Array(SAMPLE_RATE);
for (let i = 0; i < SAMPLE_RATE; i++) {
input[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
const outBig = new Float32Array(SAMPLE_RATE);
big.process([input], [outBig], blockCtx());
const outA = new Float32Array(SAMPLE_RATE / 2);
const outB = new Float32Array(SAMPLE_RATE / 2);
split.process([input.subarray(0, SAMPLE_RATE / 2)], [outA], blockCtx());
split.process(
[input.subarray(SAMPLE_RATE / 2, SAMPLE_RATE)],
[outB],
blockCtx()
);
// Tails should have similar dominant frequencies; we don't
// require sample identity because the wrap timing depends on the
// exact sample count between writes/reads, which differs across
// block segmentations.
const fBig = dominantFreqHz(
outBig.subarray(SAMPLE_RATE / 2),
SAMPLE_RATE
);
const fSplit = dominantFreqHz(outB, SAMPLE_RATE);
expect(Math.abs(fBig - fSplit)).toBeLessThan(20);
});
});
describe("PitchShifterPlugin: reset clears overlap-add state", () => {
it("after reset, a zero-input block produces a zero-output block", {
timeout: 5_000,
}, async () => {
const p = new PitchShifterPlugin({ initialSemitones: 5 });
await p.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) input[0]![i] = 1;
p.process(input, output, blockCtx());
p.reset();
const { input: in2, output: out2 } = buffers(2048);
p.process(in2, out2, blockCtx());
for (let i = 0; i < 2048; i++) {
expect(out2[0]![i]).toBe(0);
}
});
});
describe("PitchShifterPlugin: stereo input is pitch-shifted on both channels", () => {
it("two channels of input produce two channels of output", {
timeout: 5_000,
}, async () => {
const p = new PitchShifterPlugin({ initialSemitones: 12 });
await p.setup(newConfig(SAMPLE_RATE, 2), newCtx(2));
const { input, output } = buffers(SAMPLE_RATE, 2);
for (let i = 0; i < SAMPLE_RATE; i++) {
const v = 0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
input[0]![i] = v;
input[1]![i] = v;
}
p.process(input, output, blockCtx());
let leftEnergy = 0;
let rightEnergy = 0;
for (let i = SAMPLE_RATE / 2; i < SAMPLE_RATE; i++) {
leftEnergy += output[0]![i]! * output[0]![i]!;
rightEnergy += output[1]![i]! * output[1]![i]!;
}
expect(leftEnergy).toBeGreaterThan(0);
expect(rightEnergy).toBeGreaterThan(0);
});
});
describe("PitchShifterPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips semitones parameter", {
timeout: 5_000,
}, () => {
const a = new PitchShifterPlugin({ initialSemitones: 7 });
const snap = a.getPreset();
const b = new PitchShifterPlugin();
b.applyPreset(snap);
expect(b.getParameter("semitones")).toBe(7);
});
});

View File

@@ -0,0 +1,271 @@
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 { PitchShifterPluginOptions } from "../pitch-shifter.types.js";
import {
PitchShifterPluginOptionsSchema,
PitchShifterPresetDataSchema,
} from "../schemas/index.js";
const DEFAULT_WINDOW = 2048;
const TWO_PI = 2 * Math.PI;
/**
* Granular pitch shifter with two overlapping read pointers Hann-
* crossfaded into a continuous output. The input is written into a
* circular ring buffer; two read positions advance at the pitch ratio
* (`2^(semitones/12)`) and wrap by `windowSize` whenever they leave
* the valid `[writePos − windowSize, writePos]` band. Per-output-sample
* weights derived from the read pointer's distance to the write head
* smooth the wrap discontinuities, producing a steady pitch-shifted
* signal of the same length as the input.
*
* The plugin maintains its overlap-add state across blocks; `reset()`
* clears the ring + read heads. The latency from input to first
* meaningful output is `windowSize / 2` samples.
*/
export class PitchShifterPlugin extends AbstractAudioFxPlugin {
private windowSize: number;
private ring: Float32Array = new Float32Array(0);
private writePos = 0;
private readPos1 = 0;
private readPos2 = 0;
public constructor(options: PitchShifterPluginOptions = {}) {
const parsed = PitchShifterPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.pitch-shifter-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
const windowSize = opts.windowSize ?? DEFAULT_WINDOW;
super({
metadata: {
id: "pitch-shifter",
name: "Pitch Shifter",
version: "1.0.0",
category: "modulation",
},
schema: PitchShifterPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [
{
id: "semitones",
name: "Semitones",
value: opts.initialSemitones ?? 0,
min: -24,
max: 24,
default: 0,
unit: "semitones",
automatable: true,
},
],
capabilities: {
hasAudioNode: false,
hasWorklet: true,
hasSidechain: false,
sampleAccurate: true,
stateful: true,
asyncSetup: false,
preservesChannelCount: true,
latencySamples: Math.floor(windowSize / 2),
},
supportedSchemaVersion: 1,
});
this.windowSize = windowSize;
}
/**
* AudioWorklet processor source for the browser-native fast path.
* `@suaveplan/audio-engine-browser` reads this string when it wants
* to delegate pitch-shifting to an off-thread worklet rather than
* the universal block path. The returned source is self-contained:
* it declares its own AudioWorkletProcessor subclass and registers
* it under the `"suaveplan-pitch-shifter"` name.
*/
public getWorkletProcessor(): string {
return WORKLET_SOURCE;
}
protected override setupState(
config: ProcessorConfig,
_chain: ChainContext
): void {
// One ring + two read heads per channel × channels; flattened for
// cache locality and to keep the inner loop branch-free.
this.ring = new Float32Array(this.windowSize * config.channels);
this.writePos = 0;
// Start the read heads half a window apart so their Hann weights
// sum to ~1 across the output and we always have one head in the
// middle of the buffer producing audio.
this.readPos1 = -this.windowSize / 2;
this.readPos2 = -this.windowSize;
}
protected override resetState(): void {
this.ring.fill(0);
this.writePos = 0;
this.readPos1 = -this.windowSize / 2;
this.readPos2 = -this.windowSize;
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
const semitones = this.parameterValue("semitones");
const pitchRatio = 2 ** (semitones / 12);
const W = this.windowSize;
const blockSize = input[0]!.length;
const channels = input.length;
let writePos = this.writePos;
let readPos1 = this.readPos1;
let readPos2 = this.readPos2;
for (let i = 0; i < blockSize; i++) {
// Append the new input sample on every channel before sampling
// the read heads. Writing before reading keeps the read heads
// in the valid window when pitchRatio <= 1 and the heads are
// already at the trailing edge.
for (let ch = 0; ch < channels; ch++) {
this.ring[ch * W + (((writePos % W) + W) % W)] = input[ch]![i]!;
}
writePos += 1;
const d1 = wrapDistance(writePos - readPos1, W);
const d2 = wrapDistance(writePos - readPos2, W);
const w1 = 0.5 * (1 - Math.cos((TWO_PI * d1) / W));
const w2 = 0.5 * (1 - Math.cos((TWO_PI * d2) / W));
for (let ch = 0; ch < channels; ch++) {
const s1 = interpolate(this.ring, ch * W, readPos1, W);
const s2 = interpolate(this.ring, ch * W, readPos2, W);
output[ch]![i] = s1 * w1 + s2 * w2;
}
readPos1 += pitchRatio;
readPos2 += pitchRatio;
// Wrap read heads to keep them inside the analysis window.
const lag1 = writePos - readPos1;
if (lag1 < 0) {
readPos1 -= W;
} else if (lag1 > W) {
readPos1 += W;
}
const lag2 = writePos - readPos2;
if (lag2 < 0) {
readPos2 -= W;
} else if (lag2 > W) {
readPos2 += W;
}
}
this.writePos = writePos;
this.readPos1 = readPos1;
this.readPos2 = readPos2;
}
}
function wrapDistance(d: number, window: number): number {
// `d` is `writePos - readPos`, kept non-negative and ≤ window+1 by
// the wrap-on-overrun logic in `processBlock`. A single subtract is
// enough to fold a one-sample overshoot back into `[0, window]`.
return d > window ? d - window : d;
}
function interpolate(
ring: Float32Array,
channelOffset: number,
pos: number,
window: number
): number {
// Wrap fractional read into [0, window).
let wrapped = pos % window;
if (wrapped < 0) wrapped += window;
const i0 = Math.floor(wrapped);
const i1 = (i0 + 1) % window;
const frac = wrapped - i0;
const s0 = ring[channelOffset + i0]!;
const s1 = ring[channelOffset + i1]!;
return s0 * (1 - frac) + s1 * frac;
}
const WORKLET_SOURCE = `class PitchShifterProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{
name: "semitones",
defaultValue: 0,
minValue: -24,
maxValue: 24,
automationRate: "k-rate",
}];
}
constructor(options) {
super(options);
const W = (options && options.processorOptions && options.processorOptions.windowSize) || 2048;
this.W = W;
this.ring = new Float32Array(W);
this.writePos = 0;
this.readPos1 = -W / 2;
this.readPos2 = -W;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
if (!input || !input[0] || !output || !output[0]) {
return true;
}
const inBuf = input[0];
const outBuf = output[0];
const semitones = parameters.semitones[0];
const pitchRatio = Math.pow(2, semitones / 12);
const W = this.W;
let writePos = this.writePos;
let readPos1 = this.readPos1;
let readPos2 = this.readPos2;
for (let i = 0; i < inBuf.length; i++) {
this.ring[((writePos % W) + W) % W] = inBuf[i];
writePos += 1;
const d1 = ((writePos - readPos1) % W + W) % W;
const d2 = ((writePos - readPos2) % W + W) % W;
const w1 = 0.5 * (1 - Math.cos((2 * Math.PI * d1) / W));
const w2 = 0.5 * (1 - Math.cos((2 * Math.PI * d2) / W));
const wrap1 = ((readPos1 % W) + W) % W;
const wrap2 = ((readPos2 % W) + W) % W;
const i0a = Math.floor(wrap1);
const i1a = (i0a + 1) % W;
const fa = wrap1 - i0a;
const i0b = Math.floor(wrap2);
const i1b = (i0b + 1) % W;
const fb = wrap2 - i0b;
const s1 = this.ring[i0a] * (1 - fa) + this.ring[i1a] * fa;
const s2 = this.ring[i0b] * (1 - fb) + this.ring[i1b] * fb;
outBuf[i] = s1 * w1 + s2 * w2;
readPos1 += pitchRatio;
readPos2 += pitchRatio;
const lag1 = writePos - readPos1;
if (lag1 < 0) readPos1 -= W;
else if (lag1 > W) readPos1 += W;
const lag2 = writePos - readPos2;
if (lag2 < 0) readPos2 -= W;
else if (lag2 > W) readPos2 += W;
}
this.writePos = writePos;
this.readPos1 = readPos1;
this.readPos2 = readPos2;
return true;
}
}
registerProcessor("suaveplan-pitch-shifter", PitchShifterProcessor);
`;

View File

@@ -0,0 +1,4 @@
export {
PitchShifterPluginOptionsSchema,
PitchShifterPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,57 @@
# `pitch-shifter/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options and preset payload of `PitchShifterPlugin`. These run on the public boundary so any malformed object 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
- `PitchShifterPluginOptionsSchema` — validates `initialSemitones ∈ [-24, 24]` and `windowSize ∈ {512, 1024, 2048, 4096}`.
- `PitchShifterPresetDataSchema` — empty strict object; the plugin has no plugin-specific preset payload, so any extra keys are rejected.
- `windowSize` uses `z.number().int().refine(...)` rather than `z.enum([512, 1024, 2048, 4096])` because Zod's enum validator only accepts string keys; the refinement gives clear error messages on the numeric option.
## Usage: Basic Example
```ts
import { PitchShifterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-pitch-shifter";
const parsed = PitchShifterPluginOptionsSchema.parse({
initialSemitones: 7,
windowSize: 2048,
});
```
## Usage: Advanced Example
```ts
import {
PitchShifterPluginOptionsSchema,
PitchShifterPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-pitch-shifter";
// Driver for an editor form: pull the keys from the schema.
const fields = PitchShifterPluginOptionsSchema.keyof().options;
for (const field of fields) form.addRow(field);
// Strict preset validation at the IPC boundary.
function isPitchShifterPreset(blob: unknown): boolean {
return PitchShifterPresetDataSchema.safeParse(blob).success;
}
```
## API Reference
- `PitchShifterPluginOptionsSchema: z.ZodObject<{ initialSemitones?, windowSize? }>`
- `PitchShifterPresetDataSchema: z.ZodObject<{}, "strict">`
## Implementation Notes
The `windowSize` constraint is enforced by `z.refine` rather than `z.enum` because Zod's `z.enum` requires string keys. Using `z.literal(512).or(z.literal(1024))…` would work too but produces awkward type unions; the refinement keeps the inferred type as `number` while still rejecting non-power-of-two values at the boundary. The error message names the four legal values so a UI driving this schema can produce a useful dropdown.
`PitchShifterPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The plugin has no plugin-specific preset payload today: the `semitones` parameter travels through the universal parameter snapshot that `AbstractAudioFxPlugin` builds, and the empty `presetData` field marks that the plugin opts out of plugin-specific state.

View File

@@ -0,0 +1,16 @@
import z from "zod";
const POWER_OF_TWO = [512, 1024, 2048, 4096] as const;
export const PitchShifterPluginOptionsSchema = z.object({
initialSemitones: z.number().finite().min(-24).max(24).optional(),
windowSize: z
.number()
.int()
.refine((n) => (POWER_OF_TWO as readonly number[]).includes(n), {
message: "windowSize must be 512, 1024, 2048, or 4096",
})
.optional(),
});
export const PitchShifterPresetDataSchema = z.object({}).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-pitch-shifter",
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 });