Powered by AppSignal & Oban Pro

Window Functions

guides/windows.livemd

Window Functions

Mix.install([
  {:nx_signal, path: __DIR__ |> Path.join("..") |> Path.expand()},
  {:kino_vega_lite, "~> 0.1"},
  {:tucan, "~> 0.5"}
])

Introduction

When we compute a DFT we analyse only a finite slice of a signal. If the slice does not contain an exact integer number of cycles of a frequency component, that component appears to “jump” discontinuously at the edges of the window. The Fourier transform sees this as energy spread across many bins, not just the true one. This is spectral leakage.

A window function tapers the signal smoothly to zero at both ends before the FFT, eliminating the discontinuity. The cost is a slightly wider main lobe around each true frequency; the benefit is dramatically lower sidelobe levels elsewhere. Every window represents a different point on this trade-off.

Formally, multiplying the signal by a window $w[n]$ in the time domain is equivalent to convolving the spectrum with $W(f)$—the Fourier transform of the window—in the frequency domain. A narrow $W(f)$ preserves frequency resolution; low sidelobes on $W(f)$ suppress leakage.

The leakage problem in practice

A tone at exactly a DFT bin frequency leaks nothing; one between bins leaks into every other bin. 10.5 Hz sits halfway between the 10 Hz and 11 Hz bins.

fs       = 200
n        = 200
f_tone   = 10.5   # halfway between bins; worst case for leakage

t = Nx.linspace(0, n / fs, n: n, endpoint: false, type: :f32)
x = Nx.sin(Nx.multiply(t, 2 * :math.pi() * f_tone))

# Rectangular window is just the raw signal (multiply by all-ones)
rect_window = NxSignal.Windows.rectangular(n, type: :f32)
hann_window = NxSignal.Windows.hann(n, is_periodic: false)

half = div(n, 2)
freqs = NxSignal.fft_frequencies(fs, fft_length: n)[0..half] |> Nx.to_flat_list()

to_db = fn window, name ->
  amps =
    Nx.multiply(x, window)
    |> Nx.as_type({:c, 64})
    |> Nx.fft()
    |> Nx.abs()
    |> Nx.slice([0], [half + 1])

  peak = Nx.reduce_max(amps)
  amps_norm = Nx.divide(amps, peak)

  # Floor at -120 dB to avoid -inf for zero bins
  db =
    amps_norm
    |> Nx.log10()
    |> Nx.multiply(20)
    |> Nx.max(-120.0)
    |> Nx.to_flat_list()

  Enum.zip(freqs, db)
  |> Enum.map(fn {f, d} -> %{frequency: f, amplitude_db: d, window: name} end)
end

leakage_data =
  to_db.(rect_window, "Rectangular") ++
  to_db.(hann_window, "Hann")

Tucan.lineplot(leakage_data, "frequency", "amplitude_db")
|> Tucan.color_by("window")
|> Tucan.Axes.set_x_title("Frequency (Hz)")
|> Tucan.Axes.set_y_title("Amplitude (dB)")
|> Tucan.set_title("Spectral leakage: 10.5 Hz tone, rectangular vs Hann window")
|> Tucan.set_width(680)
|> Tucan.set_height(280)

The rectangular window leaks energy across the entire spectrum. The Hann window concentrates it near 10.5 Hz, with the remainder buried in the noise floor.

Window shapes

NxSignal provides seven windows. Here is how they look in the time domain:

n_shape = 64

windows_list = [
  {"Rectangular", NxSignal.Windows.rectangular(n_shape, type: :f32)},
  {"Bartlett",    NxSignal.Windows.bartlett(n_shape)},
  {"Triangular",  NxSignal.Windows.triangular(n_shape)},
  {"Hann",        NxSignal.Windows.hann(n_shape, is_periodic: false)},
  {"Hamming",     NxSignal.Windows.hamming(n_shape, is_periodic: false)},
  {"Blackman",    NxSignal.Windows.blackman(n_shape, is_periodic: false)},
  {"Kaiser β=14", NxSignal.Windows.kaiser(n_shape, beta: 14)}
]

samples = Nx.iota({n_shape}, type: :f32) |> Nx.to_flat_list()

shape_data =
  Enum.flat_map(windows_list, fn {name, w} ->
    Enum.zip(samples, Nx.to_flat_list(w))
    |> Enum.map(fn {i, v} -> %{sample: i, amplitude: v, window: name} end)
  end)

Tucan.lineplot(shape_data, "sample", "amplitude")
|> Tucan.color_by("window")
|> Tucan.Axes.set_x_title("Sample")
|> Tucan.Axes.set_y_title("Amplitude")
|> Tucan.set_title("Window functions in the time domain (N = 64)")
|> Tucan.set_width(680)
|> Tucan.set_height(260)

Frequency responses: the sidelobe comparison

This is the chart that matters most for choosing a window. Zero-padding to 1024 points interpolates the frequency axis so the sidelobe structure is clearly visible.

pad_to = 1024

freq_resp_data =
  Enum.flat_map(windows_list, fn {name, w} ->
    padded = Nx.pad(Nx.as_type(w, {:c, 64}), Nx.as_type(0, {:c, 64}), [{0, pad_to - n_shape, 0}])

    amps = Nx.fft(padded) |> Nx.abs() |> Nx.slice([0], [div(pad_to, 2)])
    peak = Nx.reduce_max(amps)

    db =
      Nx.divide(amps, peak)
      |> Nx.log10()
      |> Nx.multiply(20)
      |> Nx.max(-120.0)
      |> Nx.to_flat_list()

    # Normalised frequency 0..0.5
    bins = Enum.map(0..(div(pad_to, 2) - 1), fn k -> k / pad_to end)

    Enum.zip(bins, db)
    |> Enum.map(fn {f, d} -> %{norm_freq: f, amplitude_db: d, window: name} end)
  end)

Tucan.lineplot(freq_resp_data, "norm_freq", "amplitude_db")
|> Tucan.color_by("window")
|> VegaLite.encode_field(:y, "amplitude_db",
    type: :quantitative,
    title: "Amplitude (dB)",
    scale: [domain: [-120, 5]]
  )
|> Tucan.Axes.set_x_title("Normalised frequency (cycles/sample)")
|> Tucan.set_title("Window frequency responses: sidelobe levels (N = 64, zero-padded to 1024)")
|> Tucan.set_width(680)
|> Tucan.set_height(300)

The trade-off is plain: the rectangular window has the narrowest main lobe but the highest sidelobes (−13 dB). Blackman and Kaiser push sidelobes below −60 dB at the cost of a wider main lobe.

Key parameters

Window Peak sidelobe Typical use
Rectangular −13 dB Transients; when leakage is not a concern
Bartlett −25 dB General purpose; linear taper
Triangular −27 dB Similar to Bartlett
Hann −31 dB Default choice for spectrum analysis
Hamming −41 dB FIR filter design (non-zero endpoints)
Blackman −57 dB High dynamic range measurements
Kaiser (β=14) −60 dB+ Configurable; use when requirements are known

The Kaiser β parameter

Kaiser is unique in that a single parameter $\beta$ continuously controls the sidelobe level vs main-lobe width trade-off. As $\beta \to 0$ the window approaches rectangular; $\beta = 1$ is already nearly flat across most of the window.

kaiser_data =
  [{"β = 1",  NxSignal.Windows.kaiser(n_shape, beta: 1)},
   {"β = 5",  NxSignal.Windows.kaiser(n_shape, beta: 5)},
   {"β = 10", NxSignal.Windows.kaiser(n_shape, beta: 10)},
   {"β = 14", NxSignal.Windows.kaiser(n_shape, beta: 14)}]
  |> Enum.flat_map(fn {name, w} ->
    padded = Nx.pad(Nx.as_type(w, {:c, 64}), Nx.as_type(0, {:c, 64}), [{0, pad_to - n_shape, 0}])
    amps = Nx.fft(padded) |> Nx.abs() |> Nx.slice([0], [div(pad_to, 2)])
    peak = Nx.reduce_max(amps)
    db =
      Nx.divide(amps, peak)
      |> Nx.log10()
      |> Nx.multiply(20)
      |> Nx.max(-120.0)
      |> Nx.to_flat_list()
    bins = Enum.map(0..(div(pad_to, 2) - 1), fn k -> k / pad_to end)
    Enum.zip(bins, db)
    |> Enum.map(fn {f, d} -> %{norm_freq: f, amplitude_db: d, window: name} end)
  end)

Tucan.lineplot(kaiser_data, "norm_freq", "amplitude_db")
|> Tucan.color_by("window")
|> VegaLite.encode_field(:y, "amplitude_db",
    type: :quantitative,
    title: "Amplitude (dB)",
    scale: [domain: [-120, 5]]
  )
|> Tucan.Axes.set_x_title("Normalised frequency (cycles/sample)")
|> Tucan.set_title("Kaiser window: effect of β on sidelobe level (N = 64)")
|> Tucan.set_width(680)
|> Tucan.set_height(280)

Larger $\beta$ widens the main lobe but drives sidelobes lower. For NxSignal.Filters.firwin, the :kaiser window is available via the window: option and its $\beta$ is derived automatically from the stopband attenuation specification.

Choosing a window

If in doubt, use Hann. It is the best all-round choice for general spectrum analysis: moderate main-lobe width and sidelobes low enough for most practical SNR ranges.

Use Hamming when designing FIR filters because its non-zero endpoints give slightly better passband ripple.

Use Blackman or Kaiser when you need to measure a weak component sitting close to a strong one. The −57 dB / −60 dB sidelobe floor allows you to see signals 600× weaker than a nearby dominant tone.

Use Rectangular for transient or impulse signals where the signal is already zero at the edges, or for coherent averaging where you know the signal is periodic with exactly $N$ samples.

Connection to filtering

Windowing is multiplication in the time domain. By the convolution theorem, multiplication in time equals convolution in frequency: applying window $w[n]$ to a signal smears each spectral line by convolving it with $W(f)$.

A rectangular window has a $\text{sinc}$-shaped $W(f)$ with slow-decaying sidelobes. Smooth windows have more concentrated $W(f)$, so nearby spectral lines stay separated. This is also why NxSignal.Filters.firwin is named for the window method: it multiplies the ideal (sinc-shaped) impulse response by a window to control the filter’s transition band and stopband behaviour.