Skip to content

feat: implement firwin#29

Merged
polvalente merged 4 commits into
elixir-nx:mainfrom
thomaspmurphy:feat/firwin
Mar 9, 2026
Merged

feat: implement firwin#29
polvalente merged 4 commits into
elixir-nx:mainfrom
thomaspmurphy:feat/firwin

Conversation

@thomaspmurphy

@thomaspmurphy thomaspmurphy commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

Implements NxSignal.Filters.firwin/3, which makes FIR filter design possible using nx_signal's existing resources. Given a tap count, one or more cutoff frequencies, and a window name, it returns a vector of FIR filter coefficients that can then be applied to a signal with NxSignal.Convolution.convolve/3.

# Design a 63-tap lowpass at 1 kHz, sampling rate 44.1 kHz
h = NxSignal.Filters.firwin(63, 1000, sampling_rate: 44_100, window: :hamming)

# Apply it
filtered = NxSignal.Convolution.convolve(signal, h, mode: :same)

firwin lives in NxSignal.Filters as a deftransform. Because the number of cutoff frequencies is a compile-time constant (a literal list), the passband enumeration (Enum.chunk_every, Enum.filter, Enum.reduce) runs at JIT-compile time and emits a statically-unrolled Nx expression graph.

Reference coefficients for testing were generated with scipy.signal.firwin (SciPy 1.x, float64) and hard-coded in the test file. Tests use assert_all_close with atol: 1.0e-5 to account for the f32 vs f64 arithmetic difference. The Kaiser window tests use a looser atol: 1.0e-3 because NxSignal.Windows.kaiser uses a polynomial/asymptotic Bessel function approximation for $I_0$ rather than the Chebyshev series used by NumPy/SciPy, which introduces slightly larger errors for small arguments. This is a known divergence introduced in #27.

Possible follow-up: guides/filtering.livemd

I noticed that there are a couple of bugs in the existing filtering Livebook due to structural changes. Also, the introduction of this filter design method could simplify some of the manual setup in that file if accepted.

  • Line 61 of the current guide calls NxSignal.Filters.sinc/1, which does not exist. The sinc function now lives in NxSignal.Waveforms.

  • Line 63 calls NxSignal.Windows.hann(n: window_length). After the window API was refactored to take a mandatory positional integer argument, this passes a keyword list as the first argument, which fails the when is_integer(n) guard:

** (FunctionClauseError) no function clause matching in NxSignal.Windows.hann/2

The correct call is NxSignal.Windows.hann(window_length) I think.

  • The smart cell on line 111 is titled "FFT - 1200 Hz Low-Pass filter (Hann window); L = 1764". However, the filter in the notebook is 600 Hz with window_length = 2048 taps. This is probably a copy-paste artefact from an earlier version of the guide.

In terms of changes introduced here, the entire "Preparing the Filter" section currently does manually what firwin now encapsulates:

fc = 600
filter_indices = Nx.iota({window_length}) |> Nx.subtract(div(window_length, 2))
h_ideal =
  Nx.multiply(2 * fc / fs, NxSignal.Filters.sinc(Nx.multiply(2 * fc / fs, filter_indices)))
window = NxSignal.Windows.hann(n: window_length)   # also broken
h = Nx.multiply(h_ideal, window)

So it could be simplified thus:

h = NxSignal.Filters.firwin(window_length, fc, sampling_rate: fs, window: :hann)

The window variable in the guide is currently serving double duty: it is used for both the FIR filter design and as the STFT analysis window passed to NxSignal.stft/3 and NxSignal.istft/3. After the simplification, these two concerns would be separated:

# FIR coefficients — symmetric window applied internally by firwin
h = NxSignal.Filters.firwin(window_length, fc, sampling_rate: fs, window: :hann)

# Separate periodic Hann window for STFT analysis
stft_window = NxSignal.Windows.hann(window_length)

stft_window would then replace every occurrence of window in the stft and istft calls. scipy also has separate concepts for a filter design window (symmetric, used once to shape the FIR coefficients) and an analysis window (periodic, used repeatedly in STFT frames) - firwin defaults to symmetric and stft to window='hann_periodic'.

It would perhaps also be appropriate to add a short "Direct convolution" subsection before the existing STFT-based filtering section, demonstrating the simplest end-to-end usage of firwin:

h = NxSignal.Filters.firwin(window_length, fc, sampling_rate: fs, window: :hann)

data_filtered =
  NxSignal.Convolution.convolve(data, h, mode: :same, method: :fft)
  |> Nx.as_type(data.type)

Proposed changes (this is all working nicely locally using my local version of nx_signal in the deps)

I would be happy to add a PR addressing these. I am quite new to Elixir apologies if my code is not idiomatic—happy to work to improve it!

Comment thread lib/nx_signal/filters.ex

@polvalente polvalente left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most comments I left are readability-oriented! Great work :)

After we converge on this and merge the PR we can release a new version

Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex
Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex Outdated
Comment thread lib/nx_signal/filters.ex Outdated
@polvalente

Copy link
Copy Markdown
Collaborator

I forgot to comment: you can also modify the guides given the PR description. I don't have a hard preference on whether to do it in this PR or in a follow-up after merging. Just let me know which it'll be so that we can release a new version with the updated guides included :)

@thomaspmurphy

Copy link
Copy Markdown
Contributor Author

Very helpful feedback, thank you. Of course, I'll update the Livebook in this branch, I was just uncertain about the chicken-and-egg of bumping the version in the notebook (that can be done with the release I guess!).

Comment thread guides/filtering.livemd
@thomaspmurphy thomaspmurphy requested a review from polvalente March 9, 2026 10:27
@polvalente

Copy link
Copy Markdown
Collaborator

@thomaspmurphy can you run the formatter please?

@polvalente polvalente merged commit 912d183 into elixir-nx:main Mar 9, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants