Files
skills/docs/media/audio-fx/audio-fx-plugin-pitch-shifter/src/pitch-shifter/pitch-shifter.md
2026-05-29 08:20:56 +01:00

5.4 KiB
Raw Blame History

pitch-shifter

(no submodules)

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

import { PitchShifterPlugin } from "@suaveplan/audio-fx-plugin-pitch-shifter";

const shifter = new PitchShifterPlugin({ initialSemitones: 7 });
chain.add(shifter);

Usage: Advanced Example

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