Initial Commit
This commit is contained in:
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Construction options for `PitchShifterPlugin`. */
|
||||
export interface PitchShifterPluginOptions {
|
||||
readonly initialSemitones?: number;
|
||||
readonly windowSize?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PitchShifterPlugin } from "./pitch-shifter.js";
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PitchShifterPluginOptionsSchema,
|
||||
PitchShifterPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user