first commit
This commit is contained in:
@@ -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,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,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.
|
||||
Reference in New Issue
Block a user