Initialize NeuroAnalyzer
using NeuroAnalyzer
using CairoMakie
CairoMakie.activate!(type = "png")
using DSP
using FFTW
using Statisticsusing NeuroAnalyzer
using CairoMakie
CairoMakie.activate!(type = "png")
using DSP
using FFTW
using StatisticsThe Fourier transform decomposes a signal into its frequency components. It transforms a time-domain signal into its frequency-domain representation: for each frequency, how strongly it is present in the signal (amplitude or power) and how it is aligned in time (phase).
According to the Fourier theorem, a signal can be represented as a sum of sine waves, each with its own frequency, amplitude and phase. A periodic signal is a sum of sine waves at multiples of its fundamental frequency (a Fourier series); a finite, sampled signal of \(N\) samples is exactly a sum of \(N\) complex sine waves (the discrete Fourier transform).
The Fourier transform of a continuous-time signal \(s(t)\):
\[ X(f) = \int_{-\infty}^{\infty} s(t) e^{-i 2\pi ft} \, \mathrm{d}t \]
The discrete Fourier transform (DFT) of a signal \(s[n]\) of \(N\) samples:
\[ X[k] = \sum_{n=0}^{N - 1} s[n] e^{-i 2\pi kn/N}, \qquad k = 0, 1, \dots, N - 1 \]
The coefficient \(X[k]\) corresponds to the frequency \(f_k = k f_s / N\), where \(f_s\) is the sampling rate.
The fast Fourier transform (FFT) is an efficient algorithm to compute the DFT and its inverse (in \(N \log N\) instead of \(N^2\) operations), exploiting the symmetry and periodicity of the complex exponentials.
Each DFT coefficient is the dot product of the signal with a complex sine wave \(e^{-i 2\pi kn/N}\) of the frequency \(f_k\): the signal is multiplied by the sine wave sample by sample and the products are summed. If the signal contains an oscillation at this frequency, the products add up to a large value; otherwise they cancel out.
The DFT computed “by hand” with dot products equals the result of fft():
fs = 100
n = 200
t = (0:(n - 1)) ./ fs
s = 2 .* cos.(2π * 5 .* t) .+ sin.(2π * 12 .* t)
X_dft = zeros(ComplexF64, n)
for k in 0:(n - 1)
csw = exp.(-1im * 2π * k .* (0:(n - 1)) ./ n) # complex sine wave of frequency k × fs / n
X_dft[k + 1] = sum(s .* csw) # dot product
end
maximum(abs.(X_dft .- fft(s)))5.3799825102094e-12
The Fourier transform is lossless: the signal can be reconstructed exactly from its Fourier coefficients (by the inverse Fourier transform):
The Fourier transform returns a complex number \(X[k] = a + bi\) (a Fourier coefficient) for each frequency, where \(a\) is the real part (the similarity with a cosine) and \(b\) is the imaginary part (the similarity with a sine).
x = rand(5)
X = fft(x)5-element Vector{ComplexF64}:
2.129112604501158 + 0.0im
-0.2505075822321687 - 0.5962743907819374im
-0.1552506856378541 - 0.9041568752648635im
-0.1552506856378541 + 0.9041568752648635im
-0.2505075822321687 + 0.5962743907819374im
Magnitude (absolute value): the strength of the frequency component:
\[ \vert X \vert = \sqrt{a^2 + b^2} \]
mag = abs.(X)5-element Vector{Float64}:
2.129112604501158
0.6467589951892261
0.9173889199678323
0.9173889199678323
0.6467589951892261
Phase (angle, in radians): the timing of the frequency component, i.e. where its peaks are relative to the start of the signal:
\[ \theta = \operatorname{atan2}(b, a) \]
ph = angle.(X)5-element Vector{Float64}:
0.0
-1.9685274393216505
-1.740845735212179
1.740845735212179
1.9685274393216505
angle() (or atan(b, a)) must be used instead of \(\arctan(b / a)\): the two-argument arctangent returns angles in the correct quadrant (\(-\pi\) to \(\pi\)), and does not fail for \(a = 0\).
Phase is independent of amplitude, but when the amplitude is 0 the phase is undefined, and for small amplitudes it is dominated by noise. Coefficients of (near) zero magnitude should therefore be ignored (or their phase set to 0) when phases are interpreted.
Phases are used in neuroscience:
Phase angles are circular and must not be averaged directly (the mean of 350° and 10° is 0°, not 180°). Instead, the angles are represented as unit vectors \(e^{i\theta}\) and the vectors are averaged: the angle of the mean vector is the mean phase and its length (0–1) shows how consistent the phases are.
θ = deg2rad.([350, 10, 5, 355])
m = mean(exp.(1im .* θ))
(arithmetic_mean = rad2deg(mean(θ)), circular_mean = rad2deg(angle(m)), vector_length = abs(m))(arithmetic_mean = 180.0, circular_mean = -3.0103647900187057e-15, vector_length = 0.9905012255519767)
The DFT of \(N\) samples recorded at the sampling rate \(f_s\) gives coefficients at the frequencies
\[ f_k = k \frac{f_s}{N}, \qquad k = 0, 1, \dots \]
starting at 0 Hz (the mean of the signal), regardless of the sampling rate and the number of samples. The distance between the frequencies, the frequency resolution (bin width), is:
\[ \Delta f = \frac{f_s}{N} = \frac{1}{T} \]
where \(T\) is the duration of the signal. It depends only on the duration: 1 second of data gives 1 Hz bins, 5 seconds give 0.2 Hz bins. To distinguish two oscillations whose frequencies differ by \(\Delta f\), the signal must be at least \(1 / \Delta f\) seconds long.
freqs() returns the frequencies of the one-sided spectrum (0 Hz to the Nyquist frequency) and the Nyquist frequency:
fs = 10
n = 10
f, nyquist = freqs(n, fs)([0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 5.0)
The DFT returns \(N\) coefficients: for \(k\) up to \(N / 2\) they correspond to frequencies from 0 to the Nyquist frequency (\(f_s / 2\)); the remaining coefficients (above the Nyquist frequency) correspond to negative frequencies, in reverse order.
Negative frequencies are a consequence of representing signals with complex exponentials: a real cosine is the sum of two complex sine waves rotating in opposite directions, \(\cos(2\pi f t) = \frac{1}{2}(e^{i 2\pi f t} + e^{-i 2\pi f t})\). For a real signal (e.g. EEG), the coefficients of negative frequencies are the complex conjugates of the positive ones: the spectrum is symmetric and the amplitude of each component is split equally between the positive and the negative frequency.
Two-sided amplitude spectrum of a signal with 50 Hz (amplitude 0.7) and 120 Hz (amplitude 1) components and noise, sampled at 1000 Hz:
fs = 1000
t = (0:(1500 - 1)) ./ fs
s = 0.7 .* sin.(2π * 50 .* t) .+ sin.(2π * 120 .* t) .+ 2 .* randn(length(t))
X = fft(s)
f2 = fftshift(fftfreq(length(s), fs)) # -500 … 500 Hz
lines(f2, fftshift(abs.(X)) ./ length(s);
axis = (xlabel = "Frequency [Hz]", ylabel = "|X| / N"))┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
Therefore, for real signals only the positive frequencies are used (rfft() computes only these), and the amplitudes are doubled to account for the negative frequencies, except for 0 Hz and the Nyquist frequency, which have no negative counterpart.
The negative frequencies are needed for the inverse Fourier transform with ifft() (irfft() reconstructs a real signal from the positive frequencies only) and they differ from the positive ones for complex signals.
The amplitude spectrum is the magnitude of the Fourier coefficients, normalized to the units of the signal: divided by \(N\) and doubled for the positive frequencies (one-sided spectrum). A sine of amplitude \(A\) then has the amplitude \(A\) at its frequency.
fs = 100
t = (0:(200 - 1)) ./ fs
s = 2 .* cos.(2π * 5 .* t) .+ sin.(2π * 12 .* t)
n = length(s)
X = rfft(s)
A = abs.(X) ./ n
A[2:(end - 1)] .*= 2 # all except 0 Hz and Nyquist (even n)
f, _ = freqs(n, fs)
plot_psd(f, A; xlabel = "Frequency [Hz]", ylabel = "Amplitude [AU]")┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
The amplitudes at 5 and 12 Hz are 2 and 1, as in the signal. The units of the amplitude spectrum are the units of the signal (e.g. μV).
The power spectrum is the squared amplitude spectrum: how the power (mean square) of a signal is distributed over the frequencies:
\[ P[k] = \frac{\vert X[k] \vert^2}{N^2} \]
(doubled for the positive frequencies of the one-sided spectrum, except 0 Hz and the Nyquist frequency). With this normalization a sine of amplitude \(A\) has the power \(A^2 / 2\) (its mean square), and the powers of all frequencies sum to the mean square of the signal (Parseval’s theorem). The units are the squared units of the signal (e.g. μV²).
P = abs2.(X) ./ n^2
P[2:(end - 1)] .*= 2
(power_5Hz = P[f .== 5][1], power_12Hz = P[f .== 12][1],
sum_of_powers = sum(P), mean_square = mean(s .^ 2))(power_5Hz = 2.0, power_12Hz = 0.5000000000000002, sum_of_powers = 2.5, mean_square = 2.4999999999999996)
ftransform() returns the Fourier coefficients, amplitudes, powers and phases with this normalization (one-sided by default).
Amplitude and power spectra are appropriate for signals composed of distinct oscillations (a discrete spectrum); their values do not depend on the signal duration. For signals with a continuous spectrum (noise, EEG), the power is spread over the frequencies and the power per bin decreases with the bin width: the power spectral density should be used.
The power spectral density (PSD) is the power per unit of frequency (e.g. μV²/Hz): the power spectrum divided by the bin width \(f_s / N\):
\[ PSD[k] = \frac{\vert X[k] \vert^2}{f_s N} \]
(doubled for the one-sided PSD, except 0 Hz and the Nyquist frequency). The integral of the PSD over frequency (the sum of the PSD values × the bin width) equals the mean square of the signal. The PSD of a noise does not depend on the signal duration, which makes PSDs of different recordings comparable.
The PSD computed in this way (from the DFT of the whole signal) is the periodogram:
fs = 250
N = 1000 # 4 seconds
t = (0:(N - 1)) ./ fs
x = generate_sine(10, t, 1) .+ generate_sine(20, t, 0.5) .+ 0.5 .* randn(N)
X = rfft(x)
f, _ = freqs(N, fs)
PSD = abs2.(X) ./ (fs * N)
PSD[2:(end - 1)] .*= 2
plot_psd(f, PSD;
xlabel = "Frequency [Hz]",
ylabel = "PSD [AU²/Hz]",
title = "Periodogram")┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
(integral_of_psd = sum(PSD) * fs / N, mean_square = mean(x .^ 2))(integral_of_psd = 0.8510371551626658, mean_square = 0.8510371551626658)
The periodogram of a noisy signal is noisy itself: its variance does not decrease with a longer signal. Better estimates:
p_welch = welch_pgram(x, 250; fs = fs) # 1-second segments, 50% overlap
plot_psd(Float64.(freq(p_welch)), power(p_welch);
xlabel = "Frequency [Hz]",
ylabel = "PSD [AU²/Hz]",
title = "Welch's method",
flim = (0, 40))┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
In NeuroAnalyzer, psd() calculates the PSD of a NEURO object using Welch’s method (default), the multitaper method and others.
The DFT treats the signal as one period of an infinitely repeated signal. If an oscillation does not complete an integer number of cycles within the signal, the repeated signal has a discontinuity at its edges, and the power of the oscillation leaks into neighbouring frequencies: the peak is lower and wider and the spectrum shows side lobes.
fs = 100
t = (0:(100 - 1)) ./ fs # 1 second: 1 Hz bins
s_int = sin.(2π * 10 .* t) # 10 cycles
s_frac = sin.(2π * 10.5 .* t) # 10.5 cycles
f, _ = freqs(length(t), fs)
fig, ax = stem(f, ftransform(s_int).a; label = "10 Hz (integer number of cycles)",
axis = (xlabel = "Frequency [Hz]", ylabel = "Amplitude"))
stem!(ax, f, ftransform(s_frac).a; color = :red, label = "10.5 Hz (leakage)")
xlims!(ax, 0, 25)
axislegend(ax; position = :rt)
fig┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
Leakage is reduced by windowing (tapering): the signal is multiplied by a window that falls smoothly to zero at the edges (e.g. Hann), which removes the discontinuity. The window reduces the side lobes, but widens the main peak and reduces the amplitude (for a Hann window by half, the coherent gain of 0.5, which can be corrected by dividing by the mean of the window):
w = generate_window(:hann, length(t))
a_rect = ftransform(s_frac).a
a_hann = ftransform(s_frac .* w).a ./ mean(w)
fig, ax = lines(f, 20 .* log10.(a_rect); label = "rectangular (no window)",
axis = (xlabel = "Frequency [Hz]", ylabel = "Amplitude [dB]"))
lines!(ax, f, 20 .* log10.(a_hann); color = :red, label = "Hann window")
axislegend(ax; position = :rt)
fig┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
Zero padding appends zeros to the signal before the FFT. A signal of \(L\) samples padded with \(n\) zeros has \(L + n\) Fourier coefficients, so the frequency bins are denser: \(\Delta f = f_s / (L + n)\).
Zero padding does not add information and does not improve the frequency resolution (the ability to separate close frequencies, which depends only on the signal duration): it interpolates the spectrum, which makes it smoother and helps to locate peaks between the original bins.
fs = 100
s = sin.(2π * 10.5 .* (0:99) ./ fs) # 1 second, 10.5 Hz
f1, _ = freqs(100, fs)
f4, _ = freqs(400, fs)
fig, ax = lines(f4, ftransform(s; pad = 300).a; color = :red, label = "padded to 400 samples",
axis = (xlabel = "Frequency [Hz]", ylabel = "Amplitude"))
stem!(ax, f1, ftransform(s).a; label = "100 samples")
xlims!(ax, 0, 25)
axislegend(ax; position = :rt)
fig┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions. └ @ Makie ~/.julia/packages/Makie/dVJIx/src/scenes.jl:264
To keep the amplitudes in the units of the signal, the coefficients are normalized by the original signal length \(L\), not by \(L + n\) (ftransform(s; pad = n) does this).
Zero padding is also used:
nextpow2() returns the next power of two, fft2() pads and transforms)NeuroAnalyzer functions: pad0(s, n) appends n zeros, fft0(s, n) and rfft0(s, n) pad and transform (the coefficients are not normalized), ifft0(X, n) inverts and removes the n padded samples.
s = rand(10)
l = nextpow2(length(s))
X = fft0(s, l - length(s))
length(X)16
Tip: Always report padding (e.g. “zero-padded from 1000 to 2048 samples”).
Euler’s formula connects exponential, trigonometric and complex number representations:
\[ e^{i \theta} = \cos(\theta) + i \sin(\theta) \]
For a real angle \(\theta\), \(e^{i \theta}\) is a unit vector (magnitude 1) in the complex plane at the angle \(\theta\) from the positive real axis:

A vector of magnitude \(m\) and angle \(\theta\):
\[ m e^{i \theta} = m \cos(\theta) + i \, m \sin(\theta) \]
Conversely, any complex number \(z = x + iy\) can be written in polar form:
\[ z = \vert z \vert e^{i\theta}, \qquad \vert z \vert = \sqrt{x^2 + y^2}, \qquad \theta = \operatorname{atan2}(y, x) \]

For \(\theta = \pi\) Euler’s formula gives Euler’s identity \(e^{i \pi} + 1 = 0\).
Euler’s formula is the basis of the Fourier transform: a complex sine wave \(e^{i 2\pi f t}\) is a vector rotating \(f\) times per second, and a Fourier coefficient is a vector whose magnitude is the amplitude and whose angle is the phase of the frequency component. The same representation is used by the Hilbert transform and complex wavelets to extract the instantaneous amplitude (or power, \(\vert z \vert^2\)) and phase.
θ = π / 4 # 45°
m = 1.2
z = m * exp(1im * θ) # or: m * (cos(θ) + 1im * sin(θ))
plot_polar([angle(z) abs(z)]; ticks = true)A 2-second cosine of 10 Hz frequency, amplitude 2 (arbitrary units) and phase +30° (0.52 rad), sampled at 1000 Hz:
fs = 1000
t = collect(0:(1 / fs):(2 - 1 / fs)) # 2000 samples: exactly 20 cycles
s = generate_cosine(10, t, 2, 30)
# X contains: Fourier coefficients (X.c), amplitudes (X.a), powers (X.p), phases (X.ph)
X = ftransform(s)
f, _ = freqs(length(s), fs)plot_signal(t, s; ylabel = "Amplitude [AU]")Amplitude spectrum (amplitude 2 at 10 Hz):
plot_psd(f, X.a; flim = (0, 40), xlabel = "Frequency [Hz]", ylabel = "Amplitude [AU]")Power spectrum (power \(2^2 / 2 = 2\) at 10 Hz):
plot_psd(f, X.p; flim = (0, 40), xlabel = "Frequency [Hz]", ylabel = "Power [AU²]")The phases of frequencies without signal are given by numerical noise; they are set to zero:
X.ph[X.a .< 0.01 * maximum(X.a)] .= 0
plot_phase(rad2deg.(X.ph[1:50]), f[1:50]; unit = :deg, type = :stem)The phase at 10 Hz is 30°, as expected: the DFT measures phase relative to a cosine, so a sine has the phase −90°. This holds when the signal contains an integer number of cycles; otherwise leakage shifts the phase as well.
Because the Fourier transform is lossless, the inverse Fourier transform reconstructs the original signal from its Fourier coefficients:
\[ s[n] = \frac{1}{N} \sum_{k = 0}^{N - 1} X[k] e^{i 2\pi kn/N} \]
The inverse transform is a sum of complex sine waves (one per frequency) weighted by the Fourier coefficients.
X = ftransform(s; nf = true) # all (positive and negative) frequencies
s_ifft = real(ifft(X.c))maximum(abs.(s_ifft .- s)) # reconstruction error1.3322676295501878e-15
Tip: ftransform() returns coefficients for positive frequencies only by default (it uses rfft()). For the inverse transform with ifft(), the negative frequencies are needed: use ftransform(s; nf = true). Alternatively, irfft(X.c, length(s)) reconstructs a real signal from the positive frequencies.
X = ftransform(s)
maximum(abs.(irfft(X.c, length(s)) .- s))1.3322676295501878e-15
Modifying the Fourier coefficients before the inverse transform is a simple way of filtering: e.g. setting the coefficients of unwanted frequencies to zero removes them from the signal (in practice, filters with smooth frequency responses are preferred, see Filtering).
The Fourier transform describes which frequencies are present in the whole signal, but not when: the coefficients are computed from all samples. For a stationary signal (whose frequency content does not change over time) this is sufficient; for non-stationary signals the spectrum can be misleading:
A chirp (2 → 20 Hz) and two consecutive oscillations (5 Hz, then 15 Hz) have broad spectra that do not show how their frequency changes:
fs = 200
t = collect(0:(1 / fs):(4 - 1 / fs))
chirp = @. sin(2π * (2 * t + (20 - 2) / (2 * 4) * t^2))
steps = [ti < 2 ? sin(2π * 5 * ti) : sin(2π * 15 * ti) for ti in t]
f, _ = freqs(length(t), fs)
fig = Figure(size = (900, 450))
lines(fig[1, 1], t, chirp; axis = (title = "chirp 2–20 Hz", xlabel = "Time [s]"))
lines(fig[1, 2], f, ftransform(chirp).a; axis = (title = "amplitude spectrum", xlabel = "Frequency [Hz]"))
lines(fig[2, 1], t, steps; axis = (title = "5 Hz, then 15 Hz", xlabel = "Time [s]"))
lines(fig[2, 2], f, ftransform(steps).a; axis = (title = "amplitude spectrum", xlabel = "Frequency [Hz]"))
figEEG is non-stationary: its frequency content changes with the state of the subject and in response to events. It is therefore analyzed in short segments (epochs, which are assumed to be approximately stationary) or with time-frequency methods (short-time Fourier transform, wavelets, Hilbert transform of band-pass filtered signals), which estimate the spectrum as a function of time; see Hilbert Transform, Wavelet Transform and Spectrogram.
| Quantity | Formula (one-sided, \(0 < f_k < f_s / 2\)) | Units | ftransform() |
|---|---|---|---|
| Fourier coefficient | \(X[k]\) | c |
|
| Amplitude | \(2 \vert X[k] \vert / N\) | signal units (e.g. μV) | a |
| Power | \(2 \vert X[k] \vert^2 / N^2\) | units² (e.g. μV²) | p |
| Phase | \(\operatorname{atan2}(\Im X[k], \Re X[k])\) | rad | ph |
| PSD | \(2 \vert X[k] \vert^2 / (f_s N)\) | units²/Hz (e.g. μV²/Hz) | (psd()) |
| Frequency | \(k f_s / N\) | Hz | (freqs()) |
| Frequency resolution | \(f_s / N = 1 / T\) | Hz |