FFT Amplitude Scaling in Python: Coherent Gain, One-Sided Spectra, and Zero Padding

Recover a sinusoid's peak amplitude from a windowed FFT. Verify coherent gain correction, DC and Nyquist exceptions, odd FFT lengths, and zero-padding normalization with runnable NumPy and SciPy examples.

A signal with amplitude 2, but an FFT peak below 2

Applying a Hann window can make an FFT peak look half as large. Increasing the FFT length can make it smaller again if the denominator is wrong. Before interpreting those changes as a fault in the measured signal, check normalization, one-sided scaling, and window gain.

This tutorial focuses on reading the peak amplitude of an isolated sinusoid in the input’s physical units. A voltage input gives an amplitude axis in volts. See the FFT introduction for transform algorithms, and window selection and PSD estimation for broader window comparisons.

Fix the amplitude convention first. In \(x[n]=A\cos(2\pi f_0 n/f_s+\phi)\) , \(A\) is peak amplitude. For an ordinary sinusoid well below Nyquist, RMS amplitude is \(A/\sqrt{2}\) and peak-to-peak amplitude is \(2A\) . The function below returns neither RMS amplitude nor power spectral density.

What needs to be corrected?

Let \(N\) be the observed sample count, \(M\geq N\) the FFT length after padding, and \(w[n]\) the window. NumPy’s norm="backward" leaves the forward transform unscaled, so it computes

\[ X_w[k]=\sum_{n=0}^{N-1}x[n]w[n]e^{-j2\pi kn/M}. \]

With a rectangular window, a sinusoid at an ordinary positive-frequency bin \(k_0f_s/N\) contributes \(NA/2\) to that bin. The other half belongs to its negative-frequency conjugate. A one-sided spectrum combines those contributions and reads \(2|X[k_0]|/N\) .

A window attenuates a bin-centered component according to its mean value. This mean is the coherent gain:

\[ G_c=\frac{1}{N}\sum_{n=0}^{N-1}w[n],\qquad \widehat A[k]=\frac{2|X_w[k]|}{NG_c}=\frac{2|X_w[k]|}{\sum_n w[n]}. \]

This correction assumes an isolated, bin-centered sinusoid. It is not universally exact when the positive and negative window lobes overlap near DC or Nyquist, or when another nearby signal contributes to the same peak.

Do not hard-code a factor of two for every Hann window

scipy.signal.windows.hann(N, sym=False) produces a periodic window for spectral analysis. For \(N>1\) , its sum is \(N/2\) . The default sym=True produces a symmetric window for filter design, whose sum differs at the same length.

Compute the sum of the window you actually used, rather than inferring it from a window name. The SciPy Hann documentation explains the two conventions and their intended uses.

DC, Nyquist, and odd FFT lengths

rfft returns only the nonnegative-frequency part of a real-input transform. DC has no separate negative-frequency partner, so it must not be doubled.

BinOne-sided multiplierReason
DC (\(k=0\) )1A single component
Ordinary positive frequency2Combines a negative-frequency conjugate
Final bin when \(M\) is even1The Nyquist component at \(f_s/2\)
Final bin when \(M\) is odd2An ordinary positive frequency below \(f_s/2\)

Use FFT length \(M\) , rather than original record length \(N\) , for this decision. Padding an odd record to an even length creates an output with a Nyquist bin. The NumPy rfft documentation specifies the even/odd distinction.

The Nyquist test below uses \(A(-1)^n\) . At exactly \(f_s/2\) , samples depend on phase: a sine with phase zero produces only zeros. Those samples cannot identify the amplitude of an arbitrary-phase continuous sinusoid.

A reusable amplitude-spectrum function

The examples require Python, NumPy, SciPy, and Matplotlib. Paste both code blocks into the same file, in order, to run the assertions and save the figure.

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal.windows import hann


def amplitude_spectrum(x, fs, window=None, nfft=None):
    x = np.asarray(x)
    if x.ndim != 1 or x.size < 2 or np.iscomplexobj(x):
        raise ValueError("x must be a real one-dimensional record")
    x = x.astype(float)
    n = len(x)
    if not np.isfinite(fs) or fs <= 0 or not np.all(np.isfinite(x)):
        raise ValueError("finite samples and positive fs are required")
    m = n if nfft is None else int(nfft)
    if m < n or (nfft is not None and m != nfft):
        raise ValueError("nfft must be an integer >= len(x)")
    w = np.ones(n) if window is None else np.asarray(window, dtype=float)
    if w.shape != x.shape or not np.all(np.isfinite(w)) or w.sum() <= 0:
        raise ValueError("window must match x and have a positive sum")
    z = np.fft.rfft(x * w, n=m, norm="backward")
    a = np.abs(z) / w.sum()
    if m % 2 == 0:
        a[1:-1] *= 2
    else:
        a[1:] *= 2
    f = np.fft.rfftfreq(m, d=1 / fs)
    return f, a


fs, amplitude = 1024.0, 2.0
for n in (1024, 1023):
    k = 37
    t = np.arange(n) / fs
    x = amplitude * np.cos(2 * np.pi * (k * fs / n) * t + 0.3)
    for w in (np.ones(n), hann(n, sym=False)):
        for m in (n, 4 * n):
            f, a = amplitude_spectrum(x, fs, w, m)
            np.testing.assert_allclose(a[k * (m // n)], amplitude, atol=1e-10)
    # DC is not doubled, for either record length.
    _, dc = amplitude_spectrum(np.full(n, 1.25), fs)
    np.testing.assert_allclose(dc[0], 1.25, atol=1e-12)
    print(f"N={n}: rectangular/Hann and padded amplitude checks passed")

# An even FFT has a Nyquist bin; an odd FFT has an ordinary final bin.
n = 1024
_, a = amplitude_spectrum(0.7 * (-1.0) ** np.arange(n), fs)
np.testing.assert_allclose(a[-1], 0.7, atol=1e-12)
n = 1023
k = n // 2
_, a = amplitude_spectrum(0.7 * np.cos(2 * np.pi * k * np.arange(n) / n), fs)
np.testing.assert_allclose(a[-1], 0.7, atol=1e-10)

The assertions combine rectangular and periodic Hann windows, even and odd records, and fourfold zero padding. The chosen bin-centered sinusoid still has a peak amplitude of 2. The final tests verify that an even FFT’s Nyquist bin is not doubled, while an odd FFT’s final bin is doubled.

The function does not remove DC automatically. If mean removal is appropriate, pass x - x.mean() before windowing, and distinguish this operation from measuring the DC value. Removing the mean can itself affect low-frequency estimates, so it should not be mandatory preprocessing for every signal.

Zero padding and off-bin peaks

A one-second record with \(N=256\) and \(f_s=256\) Hz has native FFT points spaced 1 Hz apart. A sinusoid at 10.5 Hz lies between the 10 Hz and 11 Hz bins. Neither bin samples the peak center.

Coherent gain correction does not remove this scalloping loss. Increasing the FFT length to 2048 gives a display grid spaced 0.125 Hz apart, which includes 10.5 Hz in this particular example.

# Off-bin sinusoid: compare native and padded frequency grids.
n, fs, f0, amplitude = 256, 256.0, 10.5, 2.0
t = np.arange(n) / fs
x = amplitude * np.cos(2 * np.pi * f0 * t + 0.3)
w = hann(n, sym=False)
fig, ax = plt.subplots(figsize=(8, 4.5))
for m, label, marker in ((n, "256-point FFT", "o"), (8 * n, "2048-point FFT", None)):
    f, a = amplitude_spectrum(x, fs, w, m)
    ax.plot(f, a, marker=marker, label=label)
    band = (f > 5) & (f < 16)
    idx = np.flatnonzero(band)[np.argmax(a[band])]
    print(f"M={m}: peak={a[idx]:.6f} at {f[idx]:.3f} Hz")
ax.axhline(amplitude, color="black", linestyle="--", label="True peak amplitude = 2")
ax.set(xlim=(6, 15), ylim=(0, 2.2), xlabel="Frequency [Hz]", ylabel="Peak amplitude",
       title="Same 1-second record, different FFT grids (periodic Hann)")
ax.grid(alpha=0.3)
ax.legend()
fig.tight_layout()
fig.savefig("amplitude_padding.png", dpi=170)
plt.close(fig)

Hann-windowed amplitude spectra from the same record with 256- and 2048-point FFTs. The coarse grid misses the peak center; zero padding samples the same lobe more densely.

The maximum is approximately 1.698 before padding and 2.000 afterward. No additional observations were collected: the same windowed signal’s spectrum was evaluated at more frequency points. An arbitrary sinusoid’s frequency will not necessarily match a padded bin center.

Dividing by m, or by the padded length multiplied by the original window’s mean, would underestimate amplitude by a factor of eight here. The denominator remains the sum of the window applied to the original 256 samples.

Grid spacing is different from resolving two signals

  • FFT frequency-point spacing is \(f_s/M\) .
  • Observation duration is \(T=N/f_s\) and does not change with padding.
  • Separating nearby frequencies depends on observation duration, window lobe width, signal strengths, noise, and other conditions.

Zero padding helps read a peak, but it does not guarantee that two nearby sinusoids become separable. If separation is the goal, consider collecting a longer record first. A sinusoid centered on an original FFT bin and a densely sampled padded frequency grid are different conditions.

When a peak is not an amplitude measurement

Situation or goalAppropriate response
Isolated bin-centered sinusoidCorrect by the window sum and respect one-sided exceptions
Isolated off-bin sinusoidConsider padding, peak interpolation, or frequency estimation followed by sinusoidal fitting
Multiple nearby sinusoidsDo not treat one bin’s maximum as each signal’s amplitude; examine observation duration and separation
Broadband noiseConsider PSD and integration over a frequency band
Time-varying signalConsider segment analysis rather than interpreting a whole-record FFT peak as a constant amplitude

PSD uses a different normalization involving the sum of squared window values. Do not reuse the amplitude denominator for PSD. Changing the FFT to norm="forward" or "ortho" also changes transform scaling, so the denominator in this implementation would need adjustment.

Frequently asked questions

What should I divide FFT magnitudes by?

With NumPy’s default normalization and a real input, divide the windowed FFT magnitude by the sum of the original window. Double the one-sided values except DC and the Nyquist bin of an even-length FFT. This corrects an isolated bin-centered sinusoid; it does not guarantee an accurate amplitude for every peak.

Does zero padding change the amplitude normalization denominator?

No. Use the sum of the window applied to the observed samples. Dividing by the longer padded FFT length underestimates the amplitude.

Why is my Hann-windowed amplitude still too small after correction?

A frequency between FFT bins can suffer scalloping loss because the frequency grid misses the peak center. Coherent gain correction alone cannot remove this loss or interference from nearby signals.

References