All tutorials Mighty Professional
Build a Game Engine · Audio

Audio DSP & the FFT

A gunshot behind a wall should sound muffled, a music visualizer needs to know how much bass is playing right now, and a dry footstep recording should sound like it was made in a stone corridor. All three are signal processing on the samples your mixer already produces. This page covers the FFT for analyzing sound by frequency, window functions for reading a spectrum without being misled by leakage, filters for shaping it, and convolution for placing a sound in a room. Whatever runs per block runs inside the real-time callback from The Audio Engine (or a worker feeding it), so it has to be cheap and allocation-free.

Time~55 min LevelSenior PrereqsHelpful, not required: The Audio Engine (PCM, Nyquist, the callback rule)) and SIMD (vectorizing DSP loops). StackC++ & Rust
◂ Build a Game Engine Phase 6 · Audio Next · Capstone: A Complete 2D Game ▸
What you'll be able to do by the end

01Two domains

Looking at a waveform tells you how loud a sound is moment to moment, but not whether it's a bass rumble or a hiss. For that you need a second view of the same samples. A sampled signal has two equivalent representations: a sequence of amplitude samples over time (the time domain), and a set of sinusoidal components, amplitude and phase at each frequency, that sum to it (the ). The DFT is the exact, invertible bridge between them. Equalizers are designed, and spectral effects and analyzers are built, in terms of the frequency domain.

Worked example

Take a 440 Hz tone at amplitude 0.5 plus a quieter 1,320 Hz tone at 0.2, sampled at 48 kHz. In the time domain a 1,024-sample block is 1,024 numbers that wiggle in a pattern that's hard to read by eye. In the frequency domain the same block is essentially two spikes, one near 440 Hz and one near 1,320 Hz, with heights in the ratio 0.5 to 0.2. A visualizer reads the bass level straight off the low spike.

Same information, different coordinates

For a finite block the two domains carry the same information: the transform is lossless and invertible, so the frequency domain is a change of coordinates, not a lossy summary. (Perceptual codecs such as MP3 and AAC also code audio in the frequency domain, through an MDCT filterbank rather than the DFT.)

02The DFT & the FFT

An analyzer has to do this for every block, many times a second, so the cost of the transform matters. The DFT of N samples produces N complex bins; each bin's magnitude = √(re²+im²) is proportional to that frequency's amplitude and its phase = atan2(im, re) is its offset[3]. Computed directly it's O(N²). The (Cooley-Tukey) computes the same result in O(N log N) by recursively splitting the transform into even/odd halves combined with twiddle-factor butterflies[1][2].

Each bin k is the signal compared against a spinning arrow at that bin's frequency:

eq. 1 · the DFT X[k] = Σn=0..N−1 x[n] · e−i2πkn/N

In words: for bin k, multiply every sample by an arrow that spins k full turns over the block and add up the results. If the signal contains a frequency that keeps pace with the arrow, the products line up and the sum is large; anything else cancels out. Doing that for all N bins directly is N × N multiplications, which is what the FFT avoids. Hover any term to see what it stands for.

Worked example

A 1,024-sample block at 48 kHz has bins 48,000 ÷ 1,024 = 46.875 Hz apart. A real input gives 513 useful bins, from 0 Hz to 24 kHz. A 440 Hz tone lands at bin 440 ÷ 46.875 = 9.39, between bins 9 and 10. A sine of amplitude 0.5 sitting exactly on a bin would show a magnitude of 0.5 × 1,024 ÷ 2 = 256 there. The direct DFT needs 1,024 × 1,024 = 1,048,576 complex multiply-adds; the radix-2 FFT does it in 512 × 10 = 5,120 butterflies, about 200 times fewer steps.

Need a refresher on complex numbers and e−i2πkn/N?

A complex number has two parts, a real part and an imaginary part: z = re + i·im, where i is the unit defined by i² = −1. Picture it as a point on a 2D plane (real horizontal, imaginary vertical). That point has a length (its magnitude, √(re²+im²)) and an angle from the positive real axis (its phase, atan2(im, re)). Each DFT bin is one such number: length = how strong that frequency is, angle = where in its cycle it starts.

The exponential e−i2πkn/N looks scary but is just a unit-length arrow that rotates. By Euler's formula, eiθ = cos θ + i·sin θ: feeding an angle into ei(…) returns the point on the unit circle at that angle. Here e ≈ 2.718 (the natural-exponential base), π ≈ 3.14159 (half a turn), and the exponent −2πkn/N is an angle that grows with both the bin index k and the sample index n. So the DFT compares your signal against a set of spinning arrows, one per frequency, and reports how much each one matches.

The FFT is exact, not an approximation, and a real signal gives N/2+1 useful bins

The FFT computes the DFT exactly (it's an algorithm, not an estimate), and because it does far fewer operations it's typically more numerically precise than the naive DFT (less accumulated round-off)[1]. Bin k maps to frequency k·fs/N, so the resolution is fs/N. For a real input only N/2+1 bins are independent (indices 0 to N/2); the upper half is the conjugate mirror, and real-FFT routines return exactly N/2+1 values. Radix-2 needs N a power of two; mixed-radix algorithms cover composite sizes and Bluestein's (or Rader's) covers primes, all still O(N log N)[9]. Don't claim N independent bins, and don't throw away phase if you're processing (a visualizer can; reconstruction can't).

The widget computes the magnitude spectrum of a test signal every frame. The square wave shows the odd-harmonic series:

Top, the waveform (animated); bottom, its magnitude spectrum, bins 0 to N/2 of a 256-point FFT. Drag Freq, then Detune, then switch to the square wave. A pure sine on a bin drops a single spike; the square wave shows the odd harmonics (1f, 3f, 5f...) falling as 1/n. Push detune so a component lands between bins and its spike smears into a hump: spectral leakage, which §3 reduces.
A teaching radix-2 FFT (production: use a library)
// In-place radix-2 decimation-in-time. N MUST be a power of two.
// Needs <complex>, <cmath>, <utility>, <vector>.
void fftRadix2(std::vector<std::complex<float>>& samples) {
    const size_t n = samples.size();
    for (size_t i = 1, j = 0; i < n; ++i) {        // bit-reversal permutation
        size_t bit = n >> 1;
        for (; j & bit; bit >>= 1) j ^= bit;
        j ^= bit; if (i < j) std::swap(samples[i], samples[j]);
    }
    for (size_t len = 2; len <= n; len <<= 1) {          // stages: 2, 4, 8, ..., N
        float angle = -2.0f * 3.14159265f / (float)len;   // -2pi/len (forward transform)
        std::complex<float> twiddleStep(std::cos(angle), std::sin(angle));
        for (size_t blockStart = 0; blockStart < n; blockStart += len) {
            std::complex<float> twiddle(1, 0);         // e^(-i2πk/len), advanced by one step per k
            for (size_t k = 0; k < len/2; ++k) {           // the butterfly
                auto even = samples[blockStart+k], odd = samples[blockStart+k+len/2] * twiddle;
                samples[blockStart+k] = even + odd; samples[blockStart+k+len/2] = even - odd;
                twiddle *= twiddleStep;
            }
        }
    }
}
use num_complex::Complex32;
// In-place radix-2 DIT. n MUST be a power of two. Production: the `rustfft` crate.
fn fft_radix2(samples: &mut [Complex32]) {
    let n = samples.len();
    let mut j = 0usize;
    for i in 1..n {                                  // bit-reversal permutation
        let mut bit = n >> 1;
        while j & bit != 0 { j ^= bit; bit >>= 1; }
        j ^= bit; if i < j { samples.swap(i, j); }
    }
    let mut len = 2;
    while len <= n {                                // stages
        let angle = -2.0 * std::f32::consts::PI / len as f32;   // -2pi/len (forward transform)
        let twiddle_step = Complex32::new(angle.cos(), angle.sin());
        let mut block_start = 0;
        while block_start < n {
            let mut twiddle = Complex32::new(1.0, 0.0);   // e^(-i2πk/len), advanced by one step per k
            for k in 0..len/2 {                       // the butterfly
                let (even, odd) = (samples[block_start+k], samples[block_start+k+len/2] * twiddle);
                samples[block_start+k] = even + odd; samples[block_start+k+len/2] = even - odd;
                twiddle *= twiddle_step;
            }
            block_start += len;
        }
        len <<= 1;
    }
}
What's intentionally missing

This is a learning aid; ship FFTW, KissFFT, or rustfft[9]. It does a full complex FFT on real data (a real-FFT does about half the work and returns N/2+1 bins), builds each stage's twiddles by repeated multiplication instead of reading a precomputed table (the repeated product drifts by rounding at large N), has no SIMD, and supports only power-of-two sizes (no mixed-radix or Bluestein).

03Windowing & leakage

Your analyzer shows a single clean tone as a wide smear covering a dozen bins, hiding a quieter tone nearby. The cause is the block boundary. Pulling a finite N-sample block from a longer signal implicitly multiplies by a rectangular window. Unless the signal is exactly periodic in the block, the edges are discontinuous, and that smears each frequency's energy across many bins: . The rectangular window's first side lobe is only 13 dB down, so leakage is severe. A window function (Hann, Hamming, Blackman) tapers the block toward zero at the edges, applied before the FFT, cutting the side lobes[4].

There is no best window, only a tradeoff

Lower side lobes (less leakage) come at the cost of a wider main lobe (worse resolution of nearby frequencies). Julius Smith's figures: rectangular first side lobe −13 dB (narrowest main lobe); Hann ≈ −31 dB (main lobe ~2× rectangular); Hamming ≈ −43 dB; Blackman ≈ −58 dB (widest main lobe)[4][5]. Hann is a reasonable default, not universally best. For spectral analysis use the periodic ("DFT-even", denominator N) form of the window rather than the symmetric one, which is for FIR filter design[5]. And windowing reduces leakage; it never eliminates it.

Worked example

A loud tone sits halfway between bins 9 and 10 of a 1,024-point FFT, the worst case for leakage, and a tone 40 dB quieter sits at bin 20. With the rectangular window the loud tone's leakage at bin 20 is still only 24 dB down, so it buries the quiet one. With a Hann window the leakage there is 69 dB down, and the quiet tone stands clear above it. The price is that the loud tone's own peak is twice as wide.

The widget analyzes a single sine with each window:

Top, the windowed block (grey: the window); bottom, its dB spectrum. Switch windows and drag Frequency across a bin. The curve is the block zero-padded 8×, which traces the window's lobes between bins; the dots are what the plain 256-point DFT returns, one per bin. When the sine sits exactly on a bin, the rectangular window's dots land in the nulls and leakage seems to vanish; between bins they land on the side lobes. The readout measures the worst side lobe and the main-lobe width from the curve: rectangular −12.8 dB and 2.0 bins, Hann −31.5 dB and 4.0 bins, Blackman −58.0 dB and 6.1 bins at the starting frequency. Lower leakage costs resolution.

04The STFT & spectrogram

A music visualizer has to show the bass hitting on each beat as it happens. A single FFT describes one block of time. The Short-Time Fourier Transform slides a windowed FFT along the signal, transforming each chunk into a column of a 2D time × frequency map; plot magnitude (in dB) as brightness and you have a spectrogram. The hop size is how far the window advances between frames; typical analysis hops are 25 to 50% of the window, so consecutive windows overlap. Without overlap, a tapered window all but ignores transients that fall near a frame's edges.

The time-frequency tradeoff is a hard limit

A short window gives good time resolution but poor frequency resolution; a long window gives fine frequency resolution but poor time resolution. You cannot maximize both: the product of time and frequency spread is bounded below (a Fourier/uncertainty relation, minimized by a Gaussian window). No implementation can beat it. More overlap gives smoother time evolution, not finer frequency resolution; frequency resolution is set by the window length.

Worked example

At 48 kHz a 1,024-sample window spans 21.3 ms and resolves 46.9 Hz; a 256-sample window spans 5.3 ms but resolves only 187.5 Hz, too coarse to separate a 200 Hz note from a 300 Hz one. With a 50% hop the 1,024-sample analysis advances 512 samples at a time, 93.75 spectrogram columns a second.

05Digital filters

A sound behind a wall loses its high frequencies, so muffling it means turning the highs down smoothly while the lows pass through. That's a filter. A linear time-invariant digital filter of finite order is a difference equation, each output sample a weighted sum of recent inputs and recent outputs:

eq. 2 · the filter difference equation y[n] = b0x[n] + b1x[n−1] + … − a1y[n−1] − a2y[n−2] − …

In words: read it as a mixing desk on a sliding window. The new output y[n] is a weighted blend of the current and past inputs (the b terms, the feed-forward path) minus a weighted blend of past outputs (the a terms, the feedback path)[7]. A subscript like b1 just labels a coefficient; [n−1] means "one sample ago". With no feedback (no a terms) it is an FIR filter; with feedback it is IIR. Hover any symbol to see what it stands for.

Worked example

The simplest IIR low-pass is a one-pole filter: y[n] = 0.1 × x[n] + 0.9 × y[n−1], so b0 = 0.1 and a1 = −0.9 in the sign convention above. Feed it a sudden step from 0 to 1 and the output creeps up: 0.1, 0.19, 0.271, 0.344, 0.410, and reaches 0.99 after about 44 samples. Fast wiggles never get time to build up, so the highs are turned down, which is the muffling. A biquad is the same idea with two inputs and two outputs of memory.

The is a second-order IIR section (two poles, two zeros), the standard building block of audio EQ. The RBJ "Audio EQ Cookbook" gives ready coefficients for low/high/band-pass, notch, peak, and shelf filters[6]. Conceptually, zeros pull the response down, poles push it up (resonance), and a pole on or outside the unit circle is unstable.

Neither FIR nor IIR wins; cascade biquads for higher order

FIR is stable and can be linear-phase but costs taps; an IIR biquad is cheap but can go unstable and has nonlinear phase. Per-voice filtering in games is usually low-order IIR (one-pole or biquad sections); phase-critical mastering may use linear-phase FIR. A biquad is second-order, so higher-order filters are built by cascading biquads (second-order sections), which is far less sensitive to coefficient quantization than one high-order section (a small error there can push a pole across the unit circle and the output blows up)[7]. Watch denormals in the feedback path: a decaying tail eventually reaches subnormal values, so set FTZ/DAZ on the audio thread (see SIMD).

A biquad (RBJ low-pass, Direct Form I), block-processed in the audio thread
struct Biquad {
    float b0,b1,b2,a1,a2;                 // coefficients (a0 normalized to 1)
    float x1=0,x2=0,y1=0,y2=0;            // state: last two inputs/outputs
    void setLowpass(float cutoffHz, float q, float sampleRate) {
        float w0 = 2.0f*3.14159265f*cutoffHz/sampleRate;
        float cosW0 = std::cos(w0), alpha = std::sin(w0)/(2.0f*q);   // RBJ's cos(w0) and alpha
        float a0 = 1.0f + alpha;                // normalize everything by a0
        b0 = ((1-cosW0)*0.5f)/a0; b1 = (1-cosW0)/a0; b2 = ((1-cosW0)*0.5f)/a0;
        a1 = (-2*cosW0)/a0; a2 = (1-alpha)/a0;
    }
    void processBlock(float* samples, int count) {
        for (int i = 0; i < count; ++i) {
            float x0 = samples[i];            // x0 = x[n], x1 = x[n-1], y1 = y[n-1] ...
            float y0 = b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2;   // the difference equation
            x2=x1; x1=x0; y2=y1; y1=y0;       // shift history (y feedback is serial)
            samples[i] = y0;
        }
    }
};
struct Biquad { b0:f32,b1:f32,b2:f32,a1:f32,a2:f32, x1:f32,x2:f32,y1:f32,y2:f32 }
impl Biquad {
    fn set_lowpass(&mut self, cutoff_hz: f32, q: f32, sample_rate: f32) {
        let w0 = 2.0*std::f32::consts::PI*cutoff_hz/sample_rate;
        let (cos_w0, alpha) = (w0.cos(), w0.sin()/(2.0*q));  // RBJ's cos(w0) and alpha
        let a0 = 1.0 + alpha;                 // normalize by a0
        self.b0 = ((1.0-cos_w0)*0.5)/a0; self.b1 = (1.0-cos_w0)/a0; self.b2 = ((1.0-cos_w0)*0.5)/a0;
        self.a1 = (-2.0*cos_w0)/a0; self.a2 = (1.0-alpha)/a0;
    }
    fn process_block(&mut self, samples: &mut [f32]) {
        for sample in samples.iter_mut() {
            let x0 = *sample;                  // x0 = x[n], x1 = x[n-1], y1 = y[n-1] ...
            let y0 = self.b0*x0 + self.b1*self.x1 + self.b2*self.x2 - self.a1*self.y1 - self.a2*self.y2;
            self.x2=self.x1; self.x1=x0; self.y2=self.y1; self.y1=y0;   // shift history (y feedback is serial)
            *sample = y0;
        }
    }
}

Worked example

An RBJ low-pass at 1 kHz with Q = 0.707 at 48 kHz gives b0 = b2 = 0.00392, b1 = 0.00783, a1 = −1.8153 and a2 = 0.8310. At 0 Hz the gain is (b0 + b1 + b2) ÷ (1 + a1 + a2) = 0.01566 ÷ 0.01566 = 1: the bass passes unchanged. The feedforward weights are tiny and the feedback weights are large, which is why a few coefficients can produce a long, smooth response, and why their rounding matters.

The widget plots the biquad's frequency response, recomputed from the RBJ coefficients:

The curve is the filter's gain in dB from 20 Hz to 20 kHz, and the readout gives the type, cutoff and Q (317 Hz and Q 0.72 at the start). Switch types, drag Frequency, then push Q up. Low-pass rolls off the highs, high-pass the lows, band-pass keeps a band, and peak boosts one by 9 dB. Near Q 0.71 the low-pass is flat to its corner (Butterworth); raise Q and the corner sharpens into a resonant peak as the poles move toward the unit circle, so the filter rings longer after a transient. An RBJ biquad stays stable for any positive Q in exact arithmetic; poles that close to the circle are what make high-Q, low-frequency sections sensitive to coefficient rounding.

06Convolution reverb

A footstep recorded in a dry studio sounds wrong in a cathedral level, and re-recording every sound in every space isn't an option. Convolution reverb convolves the dry signal with a measured room impulse response (the recorded response of a real space to an impulse); the output sounds as if played there. Direct convolution is O(N·M), far too slow for a seconds-long IR. The convolution theorem (time-domain convolution equals frequency-domain multiplication) is why FFT convolution is fast: zero-pad both to at least N+M−1 so the FFT's circular convolution equals the linear one, FFT both, multiply the spectra, inverse-FFT, O(N log N)[1].

Worked example

A 2-second hall IR at 48 kHz is 96,000 samples. Direct convolution needs 96,000 multiply-adds for every output sample, 96,000 × 48,000 = 4.6 billion a second for one channel, far beyond a callback's budget. FFT convolution handles a whole block in a few transforms, on the order of a thousand times less arithmetic for an IR this long. Done unpartitioned, though, each block is about as long as the IR, so you'd hear the reverb up to 2 seconds late.

Plain FFT convolution adds latency; real-time uses partitioned convolution

Unpartitioned FFT convolution processes the input in blocks about as long as the IR, so its latency is on the order of the IR length: seconds, for a hall. The fix is partitioned convolution: chop the IR into blocks, convolve the input with each one by FFT, and sum the delayed results with overlap-add. Non-uniform schemes use small early blocks (low latency) and larger tail blocks (efficiency); Gardner's hybrid adds a direct-form FIR for the first samples and reaches zero input-output delay[11]. FFT convolution also beats direct only for long kernels; a short FIR is faster done directly. The alternative is algorithmic reverb built from Schroeder's delay-line units: Freeverb runs eight parallel comb filters (with a low-pass in each feedback loop) into four series all-pass filters per channel[8]. It is cheap, adds no block latency, and is fully parameterizable (size, decay, damping); a simple comb/all-pass network can sound metallic next to a measured IR, while a measured IR is fixed to one space.

07Real-time game DSP

Sixty-four voices each need a distance filter, and the whole mix needs reverb, inside a callback with a few milliseconds to spare. All of this runs inside the hard-real-time audio callback (or worker threads feeding it): no heap alloc, no locks, no I/O, no unbounded work. Filters and reverb are pre-allocated with fixed state; parameter changes arrive via the lock-free ring, not a mutex. Process whole blocks per effect (cache- and SIMD-friendly).

The DSP graph, and where SIMD does and doesn't help

Cheap per-voice filters (a distance/occlusion low-pass) run per voice; expensive shared effects (reverb, master EQ, a limiter) run once on a bus. This is the DSP graph that middleware exposes: FMOD, for example, mixes through a directed graph of DSP units, from generators at the tail to the head node that feeds the sound card, with channel groups as its buses[10]. SIMD the parallelizable loops (the magnitude loop, FFT butterflies, batches of voices), but the biquad recurrence is serial in time (y[n] depends on y[n−1]), so the straightforward way to vectorize it is across voices or channels, one filter per lane. Smooth parameter changes (ramp the cutoff and recompute coefficients every few samples, or interpolate the coefficients) instead of jumping once per block; per-block steps are audible as zipper noise.

Worked example

64 voices with a one-pole low-pass each cost 64 × 48,000 = 3.07 million multiply-adds a second, cheap. Putting a convolution reverb on each voice would multiply its cost by 64; sending all 64 to one reverb bus pays for it once. And if a cutoff only updates once per 512-sample block, it moves in 93.75 jumps a second, which you hear as a buzz: ramp it across the block instead.

Wrong answers, and why: the FFT is exact (not approximate) and real input gives N/2+1 bins (not N); and windowing trades leakage for resolution (no best window) and is applied before the FFT.

08Pitfalls

"The FFT is approximate"It's exact, and typically more precise than the naive DFT (fewer ops).
N independent binsA real signal gives N/2+1; the rest is the conjugate mirror.
"Hann is the best window"No best window. Side lobes vs main-lobe width is a tradeoff.
Longer FFT is strictly betterFiner frequency resolution, coarser time resolution.
Treating FIR or IIR as always betterFIR is stable and can be linear-phase but costs taps; IIR is cheap but rings and can go unstable.
One high-order IIR sectionQuantization can blow it up. Cascade biquads.
"FFT convolution is always faster"Only for long kernels; short FIRs are faster direct. Unpartitioned, it also adds latency.
Alloc/lock in the callbackPre-allocate; params via the lock-free ring. Mind denormals.

09What's next

Next in the series is the first milestone, the 2D capstone, which wires the loop, the sprite renderer, input, asset loading and this audio engine into a small playable game. The Audio Engine module this builds on covers the callback and mixer; the full path is on the series hub.

  1. Steven W. Smith. The Scientist and Engineer's Guide to Digital Signal Processing, ch. 12 (the FFT), 9 (the real DFT, N/2+1), 18 (FFT convolution). dspguide.com. The FFT is exact and more precise; bin counts; the convolution theorem.
  2. James Cooley and John Tukey. "An Algorithm for the Machine Calculation of Complex Fourier Series." Math. of Computation 19(90), 1965. overview. The O(N log N) divide-and-conquer FFT.
  3. Julius O. Smith III. Mathematics of the DFT (with Audio Applications). CCRMA, Stanford. ccrma.stanford.edu. The DFT definition, magnitude and phase, for audio.
  4. Julius O. Smith III. Spectral Audio Signal Processing, "Spectrum Analysis Windows." dsprelated.com. Window side-lobe/main-lobe figures (rect −13, Hann −31, Hamming −43, Blackman −58 dB) and the tradeoff.
  5. Fredric J. Harris. "On the Use of Windows for Harmonic Analysis with the Discrete Fourier Transform." Proc. IEEE 66(1), 1978. doi.org. The canonical window catalog and performance parameters, including the DFT-even (periodic) form for spectral analysis.
  6. Robert Bristow-Johnson. "Cookbook formulae for audio EQ biquad filter coefficients" (Audio EQ Cookbook). w3.org/TR/audio-eq-cookbook. The RBJ biquad coefficients for every filter type; Direct Form I recommendation.
  7. Julius O. Smith III. Introduction to Digital Filters (with Audio Applications). CCRMA. ccrma.stanford.edu. FIR vs IIR, poles and zeros, stability, the difference equation.
  8. Julius O. Smith III. Physical Audio Signal Processing, "Freeverb" / "Artificial Reverberation." ccrma.stanford.edu. Freeverb: eight parallel Schroeder-Moorer filtered-feedback combs into four series Schroeder all-passes per channel.
  9. Matteo Frigo and Steven Johnson. FFTW ("Fastest Fourier Transform in the West"). fftw.org. The production FFT: any size, O(N log N), Cooley-Tukey + Rader/Bluestein. (KissFFT, rustfft are lighter alternatives.)
  10. Firelight Technologies. FMOD Engine User Manual 2.03, "Core API: Using DSP Effects." fmod.com. The directed DSP graph from generators to the head node; effects on channels (per voice) or channel groups (per bus).
  11. William G. Gardner. "Efficient Convolution without Input-Output Delay." JAES 43(3), 1995. aes.org. Non-uniformly partitioned FFT convolution plus a direct-form head for zero-latency convolution reverb.

See also