From Theory to Differentiable Design with pyFDN
Friedrich-Alexander-Universität Erlangen-Nürnberg
Massachusetts Institute of Technology
1 September 2026
| Time | Segment | You will… | |
|---|---|---|---|
| 0:00 | 15 | What is an FDN? | read the block diagram, and where filters go in it |
| 0:15 | 15 | pyFDN, the tour | know where everything lives, run five lines |
| 0:30 | 10 | What to do next? | see where the research frontier is |
| 0:40 | 25 | Hands-on 1 — build one | delays, matrix, gains, decay, hooks — then a hall |
| 1:05 | 20 | Hands-on 2 — match a room | fit one to a measured hall, by gradient descent |
| 1:25 | 5 | Q&A | ask your questions |
Slides and setup instructions: artificial-audio.github.io/pyfdn-tutorial-2026
Strengths
Limitations
Used in modern frameworks

MPEG-I Immersive Audio

Input gain B, a bank of \(N\) delays, the feedback matrix A, output gain C, and the direct path D around all of it. Four blocks and a loop — that is the whole vanilla FDN.
Same structure, three hooks added: the points where pyFDN.process_fdn will take an arbitrary filter — the openings every later structure in this tutorial goes through.
Nothing in the loop but the orthogonal matrix A: energy goes round without being lost. Every hook that follows is a way of taking that energy back.
One shelving filter per delay line, \(T_{60}\) of 3.5 s at DC falling to 1.2 s at Nyquist. Watch the top of the spectrogram empty out first — that tilt is the absorption.
A 10-band gain_to_geq voicing on the wet signal only: \(+5\) dB low, \(-14\) dB at Nyquist. It changes the color of the reverb without touching the decay — the tail is still 3.5 s long.
td.TimeVaryingMatrix(N, 10 Hz, 1.1 rad, fs, spread=0.7) — the same hook, but the matrix moves. Orthogonal at every sample, so the decay is unchanged.
post_matrix=td.ControllableFullWaveRect(N, 0.25, [4,5,6,7]) — half the lines partly rectified.
→ One of six shimmer operators new in pyFDN 0.4.2 — Dal Santo et al., Shimmer Reverberation with Nonlinear FDNs, at this conference.
Three connectors, in both backends (td.Series / flamo.Series, …). The vanilla FDN is one Recursion inside a Series inside a Parallel — nothing in the library says “FDN”.
swap_flamo_recursion_paths exchanges the two branches of the Recursion: the short delay → matrix → short delay chain lands in the forward path, and only the long delays recirculateTwo nested recursions. The inner loop is a homogeneous MIMO allpass FDN; it and the absorption filter form the forward path of the outer loop, whose feedback path is the main delay bank.
With only 4 main delays:
Microphones feed the FDN, the FDN feeds the loudspeakers, and the room’s loudspeaker-to-microphone responses are the feedback path.
Choose a structure
Which topology — and how do you compare it against another, fairly?
Derive the parameters
From physics, from perceptual features, from a reference signal — or from intuition?
Evaluate it
Objectives, complexity, quality — measured consistently, against strong baselines.
Present it
Audible, reproducible, transparent: results someone else can hear and re-run.
For research
For education
What it is
marimo is an open-source reactive Python notebook.
.py filesWhy we use it here
Functions, not a framework
pyFDN.<thing>Torch only where it earns its place
dss_to_flamoReference-tested
.mat reference fixtures asserting parityNotebooks are the documentation
.py file — diffable, testable, importableOpen the examples gallery — every one of these is rendered with its outputs and audio.
process_fdn |
td |
FLAMO | |
|---|---|---|---|
| What it is | the FDN recursion, hard-coded | a graph of block operators | torch.nn.Modules |
| Domain | time, block-wise | time, block-wise | frequency (FFT) |
| Structure | vanilla + three hooks | anything you can wire | anything you can wire |
| Gives you | no dependencies, fast to reason about | streaming, time-varying, non-linear | gradients |
| Costs you | one topology | no gradients | torch, and an LTI assumption |
Same vocabulary in all three: Series, Parallel, Recursion, and a block-processing object with a .filter(block) method.
FDNBuild is what moves between them — design in one, render in another, train in the third, and it is the same FDN.
| Module | What is in it |
|---|---|
generate |
matrices, delays, allpass, scattering, SDN |
td |
stateful block-processing graph — no torch, no FFT |
translate |
representation changes |
train |
differentiable optimization |
eq |
absorption & equalization filter design |
auxiliary |
acoustics, math, plotting, FLAMO glue |
presets, build_io |
trained FDNs on disk, and your own |
resources |
packaged test signals and the bibliography |
build = pyFDN.fdn_build_gallery(
8, # eight delay lines
fs=48_000,
rt=2.0, # T60 at DC
rt_nyquist=0.5, # T60 at Nyquist → per-line absorption filters
io_type="ones",
rng=42,
)
build.delays # (N,) delay lengths in samples
build.A # (N, N) feedback matrix
build.B # (N, n_in) input gains
build.C # (n_out, N) output gains
build.D # (n_out, n_in) direct path
build.post_delay # (n_sos, 6, N) per-line absorption, or None if lossless
build.post_matrix # (n_sos, 6, N) a filter in the feedback path, or None
build.post_output # (n_sos, 6, n_out) output equalization, or NoneFDNBuild is the common currency: every constructor returns one, every translator accepts one, and extract_build(model) gets one back out of a trained model.
import numpy as np, pyFDN
from pyFDN import td
build = pyFDN.fdn_build_gallery(8, fs=48_000, rt=1.8, rt_nyquist=0.4, rng=0)
dry, fs = pyFDN.load_audio("synth_dry")
wet = pyFDN.process_fdn(
np.pad(dry, (0, 2 * fs)), # room for the tail
build.delays, build.A, build.B, build.C, build.D,
post_delay=td.SOSBank(build.post_delay), # the absorption the gallery designed
)Everything so far is offline: Python, torch, FFT-domain recursion. None of it runs in a DAW. adac closes that gap — it walks the FLAMO graph and emits FAUST.
From there: the FAUST web IDE, faust -o cpp, faust2juce, or adac.export_juce for a VST3. pip install adac — NumPy only.
Open example_fdn_to_faust in the examples gallery — the same three calls, with the emitted FAUST source rendered underneath.
Franchino & Schlecht, Compiling Differentiable Audio Graphs to Real-Time DSP, DAFx 2026 (arXiv:2606.21277)
| Direction | Idea | Notebook |
|---|---|---|
| Allpass FDNs | uniallpass conditions; nested/series/Poletti structures | example_allpass_FDN_* |
| Paraunitary | filter feedback matrices; delay & velvet-noise matrices | example_paraunitary_fdn |
| SDN | scattering delay networks — geometry-driven | example_sdn |
| Coupled rooms | multi-slope decay, non-exponential EDCs | example_coupled_rooms, example_multislope_rir_to_fdn |
| Time-varying | modulated matrices, stability under modulation | example_time_varying_fdn |
| Shimmer | rectifiers, ring modulation and pitch shifting in the loop | example_shimmer_fdn |
| Decorrelation | multi-output FDNs for spatial rendering | example_decorrelation |
pip installDecayFitNet is by Georg Götz, with Sebastian J. Schlecht and Ville Pulkki.
As a developer
Help maintain and stabilize.
As a student
Pick a project off the reverb list.
.py notebook in examples/As a researcher
Help integrate your research.
All of it happens in the open at github.com/artificial-audio/pyFDN — take an issue, or open one.
A — molab in your browser (recommended)
Do this before the tutorial. Nothing needs to be installed locally.
B — local Python (optional)
torch via flamo is the large download, and only Hands-on 2 needs it. Do this before arriving.
Fallback: every notebook is also rendered in the pyFDN examples gallery.
One notebook, examples/example_process_fdn.py, seven steps — three knobs, the wiring between them, and two ways past the vanilla FDN:
Open example_process_fdn.py in marimo now and run all cells. Everything in this section is plain NumPy — no torch, no FLAMO. Every code cell ends with a Try this block; that is where the exercises live.
is_orthogonal and is_unilossless check it; it is not a matter of faithNormalized echo density (Abel & Huang 2006) reaches 1 when the response is statistically Gaussian — that is “mixed”.
Same delays, same decay, same losslessness. Only the matrix changed.
Play both impulse responses. The permutation FDN is the sound of a bad reverb.
Broadband — attenuate a little on every trip round the loop, proportional to the delay length, so short and long lines die together:
Frequency-dependent — replace each scalar gain with a filter per delay line whose attenuation follows the target \(T_{60}\) across frequency:
Proportional-to-delay is what makes the decay homogeneous, and therefore predictable — a graphic-EQ absorption design preserves that per band.
delays = pyFDN.sample_delay_lengths(
N=8,
delay_range=(1000, 3000), # samples ≈ 21–62 ms at 48 kHz
distribution="geometric", # or "uniform"
coprime=True, # avoid coinciding echoes
rng=2,
)
# or set them by hand, in milliseconds:
delays = pyFDN.ms_to_smp(np.array([20, 27, 31, 37, 43, 53, 61, 71]), fs)Try three delay sets and listen to each:
(300, 600) — flutter, obvious pitch(2000, 9000) — sparse early reflections, granular onsetcoprime=False and listen for the ringingThen look at pyFDN.echo_density(ir, fs=fs) for each.
Swap in "permutation", "Householder", "Hadamard" and "orthogonal". For each: listen, then measure the mixing time with pyFDN.echo_density. Which one mixes fastest? Which is cheapest to compute?
The notebook prints the mixing time of an orthogonal and a permutation FDN with the same delays: one mixes in half a second, the other never does.
That is a complete FDN — delays, A, B, C, D is the delay state space. With an orthogonal A and nothing in the loop it is lossless, so the tail never decays. Listen to it once; it is the sound of energy with nowhere to go.
Set D = 0.5 to mix the dry signal back in, then drive a single delay line with B = np.eye(8)[:, :1] and listen to how much later the tail fills in.
# broadband: bake a homogeneous gain into the loop
g = pyFDN.rt_to_gain_per_sample(rt=1.8, fs=fs)
A_lossy = np.diag(g ** delays) @ A
# frequency-dependent: one graphic-EQ absorption filter per line, inside the loop
target_rt = np.array([2.4, 2.4, 2.3, 2.1, 1.8, 1.4, 1.0, 0.7, 0.5, 0.5])
absorption = pyFDN.decay_to_geq(target_rt, delays, fs)
build = pyFDN.FDNBuild(A=A, B=B, C=C, D=D, delays=delays, fs=fs, post_delay=absorption)
ir = pyFDN.build_to_impz(build, 3 * fs).squeeze()Then check that you got what you asked for:
Ask for something extreme — 4 s at 63 Hz and 0.2 s at 8 kHz — and see where the design stops delivering. Why does it break?
Checkpoint. Get an FDN you like the sound of. Then break it: make it metallic on purpose, and say which knob did it.
process_fdn takes a filter at three points in the loop, and each one is a whole family of reverbs. Any object with a .filter(block) method fits.
Swap post_matrix for td.ControllableFullWaveRect(8, 0.25, [4, 5, 6, 7]) — half the lines partly rectified. The harmonics you hear were never in the input; the FDN is generating them, and there is no transfer function left to plot. Take alpha up toward 1.0 and listen to how far you can push it before it stops sounding like a room.
Same three hooks as the figures in part 1: post_delay, post_matrix, post_output.
Pori concert hall — pyFDN.load_audio("s3_r4_o")
Last section of the notebook: turn the knobs you already have until you get as close as you can. Decay first — the color mostly follows. Mixing time last.
examples/example_train_fdn_to_rir.py — the same hall you just chased by hand, this time fitted by an optimizer.
float32, \(2^{17}\)-point grid:
Start the run now. The rest of this section is a commentary on what it is doing while it does it.
What we can do analytically
These are invertible designs: state the target, get the parameters. That was the last slide of Hands-on 1, and it got you close.
What we cannot
No closed form. But every one of them is a differentiable loss.
Write the objective, and let autograd find the direction of improvement.
One notebook, one target, seven steps — and three of them are the ones that actually decide whether this works:
Everything here needs torch, via FLAMO. If the install did not work, the notebook is rendered in full with its plots and audio.
FLAMO — frequency-domain differentiable audio processing (Dal Santo, De Bortoli, Prawda, Schlecht & Välimäki, ICASSP 2025)
torch.nn.Modules: Delay, Gain, Filter, SVF, …Series, Parallel, Recursion, ShellRecursion becomes a matrix inverse over FFT bins, so there is no per-sample unrolling and no vanishing-gradient problem over a 3-second tailpyFDN’s job is to hand FLAMO a correct FDN — admissible matrices, homogeneous decay, sane initialization — and to read the result back out.
Pori concert hall — pyFDN.load_audio("s3_r4_o"), trimmed to the direct sound, normalized to unit energy
In Hands-on 1 these two lines were the design. Here they are the test: computed once, fed to nothing, and compared against at the very end.
That separation is the whole reason the result means anything. A fit scored on the quantity it was optimizing tells you only that the optimizer works.
A complete FDN: random orthogonal matrix, normalized gains, flat 1 s absorption.
The one thing the measurement is allowed to set before the optimizer runs is the overall energy — one scalar on the output gain. Comment it out and watch the first fifty steps get spent on a volume knob.
| what | trained as |
|---|---|
decay, post_delay |
\(T_{60}\) in seconds |
color, post_output |
gain in dB |
| mixing, \(\mathbf{A}\) | on \(SO(N)\) |
| level, \(\mathbf{B}\,\mathbf{C}\,\mathbf{D}\) | directly |
| the delays | fixed — integers |
Train the attenuation filter’s coefficients and it diverges within a few steps, at any learning rate. Parametrize by the reverberation time instead:
\[\mathrm{RT}_k \to \underbrace{\tfrac{-60\,d_i}{\mathrm{RT}_k f_s}}_\text{dB per round trip} \to \text{biquads}\]
A positive RT is a contractive loop for every value the parameter can take. The stability constraint is gone, not enforced — and the same trick puts the output EQ in dB.
Freeze everything but the decay, scale the room’s measured \(T_{60}\) by a constant, and score it on a mel multi-resolution spectrogram distance:
| \(T_{60}\) scale | 0.4 | 0.6 | 1.0 | 1.3 | 1.6 |
|---|---|---|---|---|---|
| mel MSS (\(\times 10^{-5}\)) | 1.819 | 1.804 ← minimum | 1.855 | 1.924 | 2.009 |
| energy decay at 1 s | −67 dB | −47 dB | −31 dB | −25 dB | −21 dB |
| the room, at 1 s | −29 dB |
An FDN whose tail is 18 dB below the room’s scores better than the one that tracks it to within 2 dB. Not a bug: two rooms with the same decay still have uncorrelated fine structure, and against detail you cannot predict, silence is a better guess than the right amount of the wrong detail.
\[E[f, t] \;=\; \sum_{t' \ge t} \; \sum_{f' \ge f} \big| S[f', t'] \big|^2\]
frequency="descending" cumulates high→low only. The bottom octave loses its gradient and the fit abandons it: 63 Hz lands at 0.26 s against the room’s 2.8 s. Run it and look at the trained \(T_{60}\) curve.
the room
flat 1 s start
trained
From a flat 1 s decay and a flat EQ: 52 % → 10 % mean \(T_{60}\) error, and 1.71 dB → 0.79 dB of band-level shape. Nothing the estimators report was ever shown to the optimizer.
Both trained filters come back baked as ordinary SOS banks in post_delay and post_output. Nothing about the build remembers that an optimizer produced it — so it goes straight into build_to_impz, process_fdn, or the FAUST export from part 2.
Run dry audio through the reverb you just trained. Then reopen example_process_fdn and A/B it against the one you tuned by hand.

Sebastian J. Schlecht

Facundo Franchino

Jeremy Bai

Alma Hova

Orchisama Das

Bharadwaj Lakuduva Suresh Babu

Cristóbal Andrade

Gloria Dal Santo

Gian Marco De Bortoli
Tutorial: artificial-audio.github.io/pyfdn-tutorial-2026 · pyFDN docs: artificial-audio.github.io/pyFDN

DAFx 2026 · Cambridge, MA · pyFDN tutorial · tutorial site