Real-time pitch shifting and tempo adjustment for audio stems via Web Audio API AudioWorklet. Designed as a post-processing module for a stem-splitting webapp.
This README is written for the AI agent (you) who will integrate this package into the larger stem-splitting webapp. Here are the critical focal points.
The entire integration hinges on one interface. Your stem splitter produces decoded audio β you pass it here:
interface StemInput {
id: string; // unique key matching your splitter output (e.g. "vocals", "drums")
label: string; // display name
emoji?: string; // optional, defaults to 'π΅'
color: string; // hex color for UI theming
buffer: AudioBuffer; // THE KEY PIECE β decoded audio from your splitter
}The buffer must be a valid AudioBuffer. If your splitter returns raw ArrayBuffer or Float32Array, you need to decode it first via audioContext.decodeAudioData() or construct it manually with audioContext.createBuffer().
| Level | Import | When to use |
|---|---|---|
| Widget | PitchTempoWidget |
Drop-in UI. Pass stems, get full player. |
| Hook | useAudioEngine |
You want your own UI but managed audio graph. |
| Class | PitchTempoPlugin |
Wire directly into an existing Web Audio graph. No React needed. |
Most likely path: Use PitchTempoWidget first to get it working, then switch to useAudioEngine if you need custom UI that matches the parent app's design system.
The useAudioEngine hook tears down and rebuilds the entire audio graph when stems changes by reference. This means:
// BAD β new array every render, engine re-inits 60x/sec
<PitchTempoWidget stems={splitterOutput.map(s => ({ ...s }))} />
// GOOD β stable reference, only changes when stems actually change
const memoizedStems = useMemo(() => stemInputs, [stemInputs]);
<PitchTempoWidget stems={memoizedStems} />If your parent app stores stems in state, just pass the state variable directly β React state references are stable between renders unless you call the setter.
If the parent app already has an AudioContext (likely, if it does audio decoding or playback), pass it:
<PitchTempoWidget stems={stems} audioContext={parentCtx} />Why this matters:
- Browsers limit the number of
AudioContextinstances (usually 6) - A shared context avoids sample rate mismatches
- The plugin connects its output to
ctx.destinationvia an AnalyserNode β if you need to route audio elsewhere (e.g. a recorder or export pipeline), you'll need to use the hook/class level and connectplugin.outputNodeto your own destination
If you don't pass one, the plugin creates its own at 44100Hz.
[AudioBufferSourceNode per stem]
β
[PitchTempoPlugin.inputNode] (GainNode)
β
[AudioWorkletNode β phase vocoder]
β
[PitchTempoPlugin.outputNode] (GainNode)
β
[Per-stem GainNode] (mute/solo control)
β
[AnalyserNode] (spectrum viz)
β
[AudioContext.destination]
If you need to intercept audio (e.g. for export/download), tap into the per-stem GainNode output or the AnalyserNode output. At the hook level, you can get the AnalyserNode via engine.getAnalyserNode().
The widget renders Tailwind utility classes. Your parent app must have Tailwind CSS 4+ configured, or the widget will render unstyled. The library does NOT bundle its own CSS.
If the parent app uses a different CSS framework, you'll need to either:
- Add Tailwind to the parent app (it tree-shakes, so only used classes ship)
- Use the
useAudioEnginehook and build your own UI with the parent's design system
PARAM_META = {
pitchSemitones: { min: -3, max: 3, step: 0.1 },
tempoRatio: { min: 0.85, max: 1.15, step: 0.01 },
}These are intentionally conservative for the phase vocoder algorithm. Going beyond Β±3 semitones or Β±15% tempo produces audible artifacts. If the parent app needs wider ranges, the DSP layer needs to be swapped for Rubber Band WASM (see DocumentationPanel.tsx for licensing notes).
The phase vocoder introduces ~46ms latency (2048 samples at 44100Hz). This is the STFT analysis window and is unavoidable without reducing FFT size (which degrades quality). For a post-split tool where real-time monitoring is the use case, this is acceptable.
| File | Role |
|---|---|
src/index.ts |
Public API barrel β everything the parent imports |
src/types.ts |
The StemInput interface your splitter must conform to |
src/hooks/useAudioEngine.ts |
All audio logic β if something breaks, it's here |
src/PitchTempoWidget.tsx |
The drop-in UI component |
src/dsp/PitchTempoPlugin.ts |
DSP wrapper β parameter clamping, worklet management |
src/dsp/pitchTempoProcessor.ts |
The actual phase vocoder (AudioWorklet code as string blob) |
Files you can ignore: App.tsx, main.tsx, TestPanel.tsx, DocumentationPanel.tsx, syntheticAudio.ts β these are demo/dev-only.
The widget calls onStateChange when meaningful state changes (play/pause, pitch/tempo adjustment, mode switch, etc.) β but NOT on every animation frame. currentTime updates are internal to the widget's transport bar. If the parent needs real-time position tracking, use the hook directly and read engine.currentTime.
npm run build # Library build β dist/index.js + dist/*.d.ts
npm run build:demo # Demo app β demo-dist/index.html (single file)
npm run typecheck # tsc --noEmit
npm run dev # Vite dev server for the demo appThe parent app consumes this via:
- Monorepo:
"pitch-plugin": "workspace:*"or"file:../pitch-plugin" - npm link:
npm link ../pitch-plugin - Copy dist: Copy
dist/into the parent's vendor folder and import from there
-
AudioContext must be resumed after user gesture. The plugin handles this in
play()but if you callPitchTempoPlugindirectly, you must ensurectx.state !== 'suspended'before starting sources. -
The worklet processor code is a string blob (
PITCH_TEMPO_PROCESSOR_CODEinpitchTempoProcessor.ts). It's registered viaURL.createObjectURL(new Blob([...])). This works in all modern browsers but may need CSP adjustments if the parent app has strictscript-srcpolicies. Addblob:to your CSP if needed. -
plugin.reset()must be called before re-starting playback after a stop. The hook handles this, but if you use the class directly, forgetting this causes phase accumulator drift (audio sounds wrong on second play). -
The
channelsfield inPitchTempoPluginis a deadany[]β it exists only to satisfy a TypeScript reference inreset(). The actual channel reset happens via the worklet port message. Harmless but don't be confused by it. -
No export/download functionality built in. If the parent app needs to export the pitch-shifted audio, you'll need to route the output through an
OfflineAudioContextorMediaRecorder. The plugin'sinputNode/outputNodeAPI supports this β just connect differently.
import { PitchTempoWidget } from 'pitch-plugin';
import type { StemInput } from 'pitch-plugin';
// Minimal test: one stem, synthetic sine wave
const ctx = new AudioContext();
const buffer = ctx.createBuffer(2, 44100 * 4, 44100);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = 0.3 * Math.sin(2 * Math.PI * 440 * i / 44100);
}
buffer.copyToChannel(data, 1);
const testStems: StemInput[] = [
{ id: 'test', label: 'Test Tone', color: '#8b5cf6', buffer }
];
// Render β should show widget with one stem, play button works
<PitchTempoWidget stems={testStems} />If this plays a 440Hz tone and the pitch knob shifts it audibly, integration is working.