Skip to content

DFT and FFT

ModuleM12.4 · build · Python · Pass 12 · 4 h
You buildpython/tinyllm/sig/fft.py: dft_matrix, a mixed-radix fft (radices 4, 2, 3, 5) and ifft, rfft and irfft from one half-size complex FFT, fft_convolve (the convolution theorem), and dct2_ortho (the DCT-II via an FFT)
Contractcourse/contracts/py/tinyllm/sig/fft.pyi
Testscourse/tests/M12.4/test_fft.py (what they check: section 4), golden values from course/oracle/M12.4/fft_numpy.py (numpy.fft, scipy.fft.dct) in course/fixtures/M12.4/fft_numpy.npz
Needsno module code. Reading: M00.2 (Euler’s formula, roots of unity), M03.3 (orthogonal and unitary matrices), M09.3 (error bounds that grow with log⁡n\log n)
Used byM12.5 (stft calls rfft, istft calls irfft) · later data.10 (dct2_ortho in pHash, with B14’s data group)
MilestoneMS-P12 (the multimodal gate)
Optional depthCooley and Tukey, “An algorithm for the machine calculation of complex Fourier series” (1965); Van Loan, Computational Frameworks for the Fast Fourier Transform, ch. 1 to 2; Makhoul, “A fast cosine transform in one and two dimensions” (1980)
  • The DFT is multiplication by Wkj=e−2πikj/nW_{kj} = e^{-2\pi i kj/n}; W/nW/\sqrt{n} is unitary, so the transform keeps energy (Parseval) and its inverse is the conjugate transform divided by nn (test_dft_matrix_is_unitary, test_inverse_and_parseval).
  • An FFT splits xx into pp interleaved subsequences, transforms them, and recombines them with twiddle factors and one size-pp DFT; mixed radices cover n=400=4⋅4⋅5⋅5n = 400 = 4 \cdot 4 \cdot 5 \cdot 5 (test_fft_matches_numpy, test_fft_equals_the_dft_matrix).
  • A real signal’s spectrum is conjugate symmetric, so rfft computes n/2+1n/2 + 1 bins from one complex FFT of size n/2n/2 (test_hand_example_dft, test_rfft_and_irfft_match_numpy).
  • The convolution theorem gives circular convolution; zero-padding to ≥\ge len(a) + len(b) - 1 makes it linear (test_fft_convolve_is_linear_convolution).
  • The orthonormal DCT-II is a reordered FFT (test_dct_matches_scipy_and_is_orthogonal).
Terminal window
ol start M12.4 # stubs python/tinyllm/sig/fft.py into your repo
ol tests M12.4 # read the test catalog first: rung R0, you write no tests here
ol check M12.4 # exit code is the verdict
ol diff M12.4 # after passing: your code against the reference

Whisper’s frontend (L14.1) takes a Fourier transform of every 25 ms frame of audio: 3000 frames of 400 samples for each 30 s clip. Multiplying each frame by a 400×400400 \times 400 DFT matrix costs 160000 complex multiplies per frame; an FFT does it in about 4000. More importantly, 400 is not a power of two, so the radix-2 FFT of most textbooks cannot transform it at all, and padding to 512 changes the frequencies of the bins and breaks parity with Whisper. This module builds the transform the STFT (M12.5) and the perceptual hash in data.10 both stand on, and checks it against numpy to within a frozen rounding bound.

SymbolMeaningType / shape
nntransform lengthint
ωn=e−2πi/n\omega_n = e^{-2\pi i/n}primitive nn-th root of unitycomplex
WW, Wkj=ωnkjW_{kj} = \omega_n^{kj}DFT matrixcomplex128[n, n]
X=WxX = Wxspectrum of xxcomplex128[n]
pp, m=n/pm = n/pradix and subsequence lengthints
YrY_rDFT of the subsequence x[r],x[r+p],…x[r], x[r+p], \dotscomplex128[m]
h=n/2h = n/2half length for the real FFTint
ε\varepsilonfloat64 machine epsilon, 2−522^{-52}float

The DFT writes nn samples as coefficients of nn complex sinusoids, X[k]=∑j=0n−1x[j] ωnjk,x[j]=1n∑k=0n−1X[k] ωn−jk.X[k] = \sum_{j=0}^{n-1} x[j]\, \omega_n^{jk}, \qquad x[j] = \frac{1}{n} \sum_{k=0}^{n-1} X[k]\, \omega_n^{-jk}. The rows of WW are orthogonal: ∑jωnjkωnjl‾=∑jωnj(k−l)\sum_j \omega_n^{jk} \overline{\omega_n^{jl}} = \sum_j \omega_n^{j(k-l)}, a geometric series of roots of unity that is nn if k=lk = l and 0 otherwise. So U=W/nU = W/\sqrt{n} satisfies UHU=IU^H U = I: it is unitary (M03.3), it keeps lengths, and that is Parseval’s identity ∑∣x∣2=1n∑∣X∣2\sum |x|^2 = \frac1n \sum |X|^2. The inverse is the conjugate transform: x=WXˉ‾/nx = \overline{W \bar{X}}/n, which is how ifft reuses fft. dft_matrix reduces kj mod nkj \bmod n before computing the phase, so every entry is evaluated at an angle in [0,2π)[0, 2\pi).

For circular convolution (a⊛b)[j]=∑la[l] b[(j−l) mod n](a \circledast b)[j] = \sum_l a[l]\, b[(j - l) \bmod n], the DFT turns convolution into a product: DFT⁡(a⊛b)=DFT⁡(a)⋅DFT⁡(b)\operatorname{DFT}(a \circledast b) = \operatorname{DFT}(a) \cdot \operatorname{DFT}(b). Linear convolution, what numpy.convolve computes, has length L=len(a)+len(b)−1L = \text{len}(a) + \text{len}(b) - 1; padding both inputs with zeros to any m≥Lm \ge L makes the wrap-around terms vanish, so the circular result equals the linear one. fft_convolve picks the smallest m≥Lm \ge L whose prime factors are 2, 3, 5.

Split xx into pp interleaved subsequences xr[t]=x[r+pt]x_r[t] = x[r + pt], t<m=n/pt < m = n/p, with DFTs YrY_r of size mm. Writing k=qm+k′k = qm + k' with 0≤k′<m0 \le k' < m, 0≤q<p0 \le q < p: X[qm+k′]=∑r=0p−1ωprq(ωnrk′ Yr[k′]).X[qm + k'] = \sum_{r=0}^{p-1} \omega_p^{rq} \Big( \omega_n^{rk'}\, Y_r[k'] \Big). So the size-nn DFT is pp size-mm DFTs, a multiply by the twiddle factors ωnrk′\omega_n^{rk'}, and a size-pp DFT for each k′k'. Recursing until m=1m = 1 costs O(n∑pi)O(n \sum p_i) operations, O(nlog⁡n)O(n \log n) for small radices. With radices 4, 2, 3, 5 every n=2a3b5cn = 2^a 3^b 5^c works: 400=4⋅4⋅5⋅5400 = 4 \cdot 4 \cdot 5 \cdot 5, Whisper’s frame. A size with another prime factor (7, 11, …) is a ValueError, not a silent O(n2)O(n^2) fallback. Each level adds a few roundings, so the error grows like log⁡n\log n: the tests freeze the bound 8log⁡2(max⁡(n,2)) ε ∥x∥28 \log_2(\max(n, 2))\, \varepsilon\, \|x\|_2 per entry (M09.3).

If xx is real, X[n−k]=X[k]‾X[n - k] = \overline{X[k]}, so the bins 0…n/20 \dots n/2 determine everything. Pack the even samples as real parts and the odd ones as imaginary parts, z[t]=x[2t]+i x[2t+1]z[t] = x[2t] + i\, x[2t+1], and take one complex FFT ZZ of size h=n/2h = n/2. The DFTs of the even and odd samples untangle as E[k]=12(Z[k]+Z[(h−k) mod h]‾),O[k]=−i2(Z[k]−Z[(h−k) mod h]‾),E[k] = \tfrac12\big(Z[k] + \overline{Z[(h-k) \bmod h]}\big), \qquad O[k] = -\tfrac{i}{2}\big(Z[k] - \overline{Z[(h-k) \bmod h]}\big), and X[k]=E[k]+ωnkO[k]X[k] = E[k] + \omega_n^k O[k] for k<hk < h, with the Nyquist bin X[h]=E[0]−O[0]X[h] = E[0] - O[0] because ωnh=−1\omega_n^h = -1. irfft runs the steps backwards; the imaginary parts of X[0]X[0] and X[h]X[h] must be zero for a real signal, so it drops them first, as numpy does.

pHash (data.10) takes the orthonormal DCT-II, y[k]=sk⋅2∑jx[j]cos⁡πk(2j+1)2ny[k] = s_k \cdot 2 \sum_j x[j] \cos\frac{\pi k (2j+1)}{2n} with s0=1/(4n)s_0 = \sqrt{1/(4n)} and sk=1/(2n)s_k = \sqrt{1/(2n)}. Makhoul’s trick reorders the input into v=(x0,x2,x4,…,x5,x3,x1)v = (x_0, x_2, x_4, \dots, x_5, x_3, x_1) (even samples forward, odd samples backward); then y[k]=2skRe⁡(e−iπk/(2n)V[k])y[k] = 2 s_k \operatorname{Re}\big(e^{-i\pi k/(2n)} V[k]\big) with V=FFT⁡(v)V = \operatorname{FFT}(v). The scales make the DCT matrix orthogonal, which the test checks by transforming the identity.

x=(1,2,0,−1)x = (1, 2, 0, -1), n=4n = 4, ω4=−i\omega_4 = -i:

kk∑jx[j](−i)jk\sum_j x[j] (-i)^{jk}X[k]X[k]
01+2+0−11 + 2 + 0 - 122
11+2(−i)+0−1⋅(i)1 + 2(-i) + 0 - 1 \cdot (i)1−3i1 - 3i
21−2+0−1⋅(−1)1 - 2 + 0 - 1 \cdot (-1)00
31+2(i)+0−1⋅(−i)1 + 2(i) + 0 - 1 \cdot (-i)1+3i1 + 3i

X[3]=X[1]‾X[3] = \overline{X[1]}, so rfft returns (2,1−3i,0)(2, 1 - 3i, 0). Parseval: ∑∣x∣2=1+4+0+1=6\sum |x|^2 = 1 + 4 + 0 + 1 = 6 and 14(4+10+0+10)=6\frac14 (4 + 10 + 0 + 10) = 6. Through the packing of 2.4: z=(1+2i,0−i)z = (1 + 2i, 0 - i), Z=(1+i,1+3i)Z = (1 + i, 1 + 3i); then E[0]=12((1+i)+(1−i))=1E[0] = \frac12((1+i) + (1-i)) = 1, O[0]=−i2(2i)=1O[0] = -\frac i2 (2i) = 1, so X[0]=1+1=2X[0] = 1 + 1 = 2 and X[2]=1−1=0X[2] = 1 - 1 = 0. This is test_hand_example_dft.

def dft_matrix(n, unitary=False) -> NDArray: ...
def fft(x) -> NDArray: ... # last axis, n = 2^a 3^b 5^c
def ifft(X) -> NDArray: ...
def rfft(x) -> NDArray: ... # n // 2 + 1 bins, one FFT of size n/2
def irfft(X, n) -> NDArray: ...
def fft_convolve(a, b) -> NDArray: ... # == numpy.convolve(a, b)
def dct2_ortho(x, axis=-1) -> NDArray: ... # == scipy.fft.dct(x, 2, norm='ortho')
TestKINDChecksWhy it matters downstream
test_hand_example_dftunit, smokesection 3’s spectrum, its rfft half, and Parsevalsign and packing conventions
test_dft_matrix_is_unitarypropertyUHU=IU^H U = I for several nn; one entry by formula; n=0n = 0 rejectedthe transform keeps energy
test_fft_matches_numpygolden19 sizes up to 512, including 240 and 400, and a batch, within the frozen boundM12.5 and L14.1 parity
test_fft_equals_the_dft_matrixdifferentialmixed-radix sizes against WxW xthe FFT is only fast multiplication
test_rfft_and_irfft_match_numpygoldenrfft and irfft for every even size; irfft drops imaginary DC and Nyquist partsevery STFT frame
test_inverse_and_parsevalpropertyifft(fft(x)) = x, Parseval, irfft(rfft(r)) = ristft and energy bookkeeping
test_fft_convolve_is_linear_convolutiondifferentialequals numpy.convolve, including lengths that need padding to 12the convolution theorem, done right
test_dct_matches_scipy_and_is_orthogonalgoldenscipy’s orthonormal DCT-II on vectors and a 32 x 32 block; orthogonalitydata.10’s pHash
test_unsupported_sizes_raiseboundaryprime factors 7 and 11, empty input, odd rfft, complex rfft, wrong bin count; inputs untouchedmisconfigured frames fail loudly
PitfallSymptomCaught by
1. twiddle factors with +2πi+2\pi ievery size above 4 wrong; spectra mirroredtest_fft_matches_numpy, test_fft_equals_the_dft_matrix (mutant s01)
2. Nyquist bin as E[0]+O[0]E[0] + O[0]the last rfft bin wrongtest_hand_example_dft, test_rfft_and_irfft_match_numpy (mutant s02)
3. ifft without the 1/n1/nround trips scaled by nntest_inverse_and_parseval (mutant s03)
4. multiplying unpadded spectracircular wrap-around; wrong lengthtest_fft_convolve_is_linear_convolution (mutant s04)
5. scaling bin 0 like the others in the DCTthe DC coefficient 2\sqrt2 too large; not orthogonaltest_dct_matches_scipy_and_is_orthogonal (mutant s05)
6. keeping an imaginary DC part in irffta spurious alternating componenttest_rfft_and_irfft_match_numpy (mutant s06)
7. splitting into contiguous blocks instead of interleaved subsequencesa different transform that still has the right shapetest_fft_matches_numpy, test_fft_equals_the_dft_matrix (mutant s07)
accepting n=0n = 0 in dft_matrixan empty matrix instead of an errortest_dft_matrix_is_unitary (mutant m01)
accepting extra bins in irfftthe trailing bins silently ignoredtest_unsupported_sizes_raise (mutant m02)
accepting complex input in rfftthe imaginary part discardedtest_unsupported_sizes_raise (mutant m03)
DirectionModuleHow it uses this
BackM00.2roots of unity from Euler’s formula (reading)
BackM03.3unitary matrices keep lengths (reading)
BackM09.3the log⁡n\log n error bound (reading)
ForwardM12.5stft runs rfft on every frame; istft runs irfft
Forwarddata.10pHash’s 32 x 32 dct2_ortho (with B14’s data group)
ForwardS-M12DFT properties, the convolution theorem, FFT operation counts
Your pieceProduction equivalentWhat it addsWhere to look
fftpocketfft (numpy, scipy)radices up to 11 plus Bluestein for large primes, cache-friendly plansnumpy/fft/_pocketfft_umath.cpp
fftFFTWplans tuned by measurement (“wisdom”), codelets generated by genfftFrigo and Johnson, Proc. IEEE 2005
rfft in Rustrustfft and realfft (the engine, L15.1)SIMD butterflies; the same half-size packingrealfft crate docs
fft in Coptional L14.6mixed-radix real FFT plans for the Whisper frontendc/src/kernels/audio.c