A Pitch used to keep the length only for the pitch a voice started at.
Set sound FX parameters changed it by speed, so a sweep made the sound
longer or shorter, and length into time no longer spanned the sound.
Now a way with a Pitch is played by an AudioWorklet, a phase vocoder
whose pitch is an AudioParam in semitones. The length never changes,
and a pitch glides, ramps and eases like a Gain.
- PitchPlayer (core/soundfx) is the vocoder as a plain class, so it
runs in a unit test. Frames of about 43 ms, four overlapping, a real
FFT done as a complex one of half the size.
- Peaks are moved with the bins around them (Laroche and Dolson's
phase locking), and frames are turned by half their size so a phase
is the one at the middle. Without both, a tone beat against itself
and dipped to 0.54 of its loudness while the pitch moved.
- It reads the sound itself rather than being fed it. It looks ahead
as far as a frame reaches, so a voice starts with its first sample
and every way of a Mix stays in step. A change of pitch is heard
about half a frame (20 ms) late.
- At exactly 0 a frame is the sound itself and costs next to nothing.
Coming back to 0, the phases are pulled round to the sound's for
about half a second before it switches, so the switch does not dip.
- pitchWorklet (viewport) is the processor, bundled by Vite through
?worker&url. It is loaded with the first play that has a Pitch, which
waits for it like for its bytes. A file's samples are sent with its
first voice only and kept in the worklet's scope.
- A way has the worklet when its plan has a Pitch, a typed-in 0
excepted (FxChain.shifted). A way without one stays a plain buffer
source. Without AudioWorklet a Pitch is not heard, and the console
says so.
- The pitched copies (WSOLA, pitchShift.ts) and their cache are gone.
- Decisions of the user: the player at the source rather than an
effect in the chain, a phase vocoder rather than WSOLA, no limit on
voices, no range tighter than the node's -64 to +64.
- stats() says whether the worklet is there, levels() gives a pitch in
semitones, and __gm.sound.pitchWorkletUrl() lets a check run it.
Tests:
- pitch-player.test.ts (new, replaces pitch-shift.test.ts): 11 tests.
Pitches from -30.5 to +12 within 2 %, the length to the sample, 0
exact to 1e-5, a sweep half a frame behind, 8 kHz sounds, loops,
ramps there and back without a dip.
- sound-library.test.ts: 23 tests (+4), soundfx.test.ts: 31 (+1).
- Counter-checks: no worklet in the player, shifted taken from the
value, no pull back to 0. 15 tests fail, files restored by cmp.
- e2e 34 on melektaus-atx: ALL PASSED, 34 checks (+1). In real Chrome
the worklet is there and the pitch param glides to 3.0007.
Offline: 880.0 Hz for 440 an octave up, 48000 of 48000 samples, a
sweep 0 to -12 at 392 then 248 Hz, still 48000 samples.
- Gate on melektaus-atx: 242 test files, 3690 tests, CHECK EXIT 0.
- Cost: 53 ms per second of a stereo voice at +5 in Node on stein2,
5 ms at 0. In headless Chrome 16 voices for 2 s took 1.3 s, about 24
fill the audio thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>