Task 2 of 5
Task 1's answer has a hole in it. Take a pure tone at bin 8 and correlate it against a cosine at bin 8: you get 128, the whole amplitude. Now delay that identical tone by 8 samples out of 256 — nothing about the sound has changed — and the same correlation reads 0. The tone is right there and the number says it is absent, because a delay of 8 samples is a quarter turn at bin 8, and a cosine and a sine of the same frequency are orthogonal.
The fix is to measure against both: a cosine and a sine. Two numbers per bin,
and the pair rotates as the signal shifts while its length — √(re² + im²) —
stays put. That pair is what "complex" means here. It is arithmetic on pairs of floats,
not a mysterious new number system: re is what the cosine measured,
im is what the sine measured (with a minus sign, from the standard
e^(−2πi·ki/n)), and nothing else about it needs believing.
The convention this whole track uses. gpu.js has no complex type, so a
complex result is two planes of floats: output: [n, 2], read
as result[p][i] — plane p = 0 is the real part, plane
p = 1 the imaginary part, i the bin. Since
output: [w, h] is indexed [y][x], this.thread.y
is the plane and this.thread.x is the bin. Here n is 1 —
one bin, two planes.
output: [1, 2], plane 0 the cosine correlation, plane 1 the sine correlation
with a minus sign — and log the real part, the imaginary part and the magnitude for both
tone and shiftedTone.output: [1, 2] — read back as result[plane][0]this.thread.y: plane 0 adds signal[i] * Math.cos(angle), plane 1 subtracts signal[i] * Math.sin(angle)Math.sqrt(re * re + im * im) for each signal128, while the real parts read 128 and 0Every thread runs the same body, so the plane has to be a branch inside it.
this.thread.y is 0 for the real plane and 1 for the imaginary one:
if (this.thread.y === 0) acc += signal[i] * Math.cos(angle);
else acc -= signal[i] * Math.sin(angle);The forward transform correlates against e^(−2πi·ki/n), and
e^(−iθ) = cos(θ) − i·sin(θ). The minus rides on the sine, which is why
plane 1 subtracts. Getting that sign wrong mirrors the entire spectrum, so it is worth
fixing here where there is only one bin to look at.
With output: [1, 2] the result is two rows of one value:
const out = bin8(tone);
const re = out[0][0];
const im = out[1][0];
console.log('magnitude:', Math.sqrt(re * re + im * im));cufftComplex (interleaved re/im pairs)
and planar layouts, VkFFT does the same, and WebGPU compute shaders usually reach for a
vec2<f32>. Planes suit gpu.js because an output axis is free; the
arithmetic underneath is identical whichever way the bytes are arranged.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.