Practical guide

Transform and filter signals

The signal example runs batched FFTs, frequency-domain filters, FIR filters and streaming biquads on the CPU or GPU. Every stage uses the same native compute source on both backends. The helpers allocate working buffers and keep the stages in order.

# Run this example on the CPU.
./build/example_signal
# Run this example on the requested headless GPU profile.
./build/example_signal --gpu es31

Choose the operation

Operation Input What it does
fft1d Complex vec2 blocks Forward or normalized inverse FFT
fft_filter Complex vec2 blocks FFT, frequency mask, inverse FFT
signal_window_kernel Complex vec2 blocks Rectangular, Hann, Hamming or Blackman window
fir_pipeline Real float blocks Causal finite impulse-response filter
biquad_pipeline Real float blocks Stateful second-order recursive filter

Buffers use channel-major order. Store all samples of channel zero, then all samples of channel one, and so on. Sample i in channel c is at c * count + i. A complex value uses .x for its real part and .y for its imaginary part. Store a real FFT input as vec2(value, 0).

Run a forward and inverse FFT

fft_support.hpp provides a radix-two FFT plan for a positive power-of-two length, including length one. Supply blocks of that exact size and add application-controlled padding when needed.

#include "examples/fft_support.hpp" // Use the FFT plan and buffer types.
#include <cmath> // Generate a known input tone.

const unsigned count = 256; // Choose a power-of-two block size.
shdr::buffer<shdr::glsl::vec2> input(count); // Store one real-valued channel.
shdr::buffer<shdr::glsl::vec2> spectrum(count); // Hold its complex frequency bins.
shdr::buffer<shdr::glsl::vec2> restored(count); // Hold inverse reconstruction.
auto* samples = input.data(); // Fill host data before running the plan.
for (unsigned i = 0; i < count; ++i) { // Generate a tone exactly on FFT bin twenty.
    const double phase = 6.283185307179586 * 20.0 * i / count; // Convert sample position to phase.
    samples[i] = shdr::glsl::vec2(float(std::sin(phase)), 0.f); // Keep the imaginary input zero.
} // The complete input block is now ready.

shdr_examples::fft1d forward(input, spectrum, count); // Plan an unscaled forward transform.
shdr_examples::fft1d inverse(spectrum, restored, count, 1, true); // Plan normalized inverse output.
shdr::pipeline processing; // Collect the linked dispatches in execution order.
forward.append_to(processing); // Produce the spectrum first.
inverse.append_to(processing); // Reconstruct from that same buffer.
processing.run(); // Use run(gpu) to execute the same stages on a headless device.

Attach fft_bit_reverse.cpp and fft_butterfly.cpp from examples/shaders with shdr_target_shaders. Keep both plan objects alive while their appended stages run. processing_pipeline() returns a plan's pipeline. append_to(pipeline) connects its stages to a larger GPU-resident chain. Join existing sequences with pipeline += another_pipeline or first_pipeline >> second_pipeline.

The forward transform uses the negative complex exponential with unit scale. The inverse uses the positive exponential and applies 1/count once in its last stage. Forward followed by inverse reconstructs the input within float precision. Bins use ordinary unshifted order. Bin zero is DC, and bin count/2 is Nyquist for an even length.

A plan has a bit-reversal dispatch followed by log2(count) butterfly stages. Working buffers give each stage separate read and write storage, allowing input and final output to alias. Twiddle phases are calculated once on the host and reused. Work per channel is O(count * log(count)), using independent invocations within each ordered dispatch.

Use the fourth constructor argument for several channels. Allocate at least count * channels complex values in each bound buffer. Plans have fixed dimensions, so create another plan when those dimensions change.

Apply a low-pass, high-pass or band filter

fft_filter links the forward transform, a real gain for each bin and the inverse transform. Add signal_frequency_mask.cpp to the FFT shader sources.

const float rate = 1000.f; // Samples arrive one millisecond apart.
shdr_examples::fft_filter band( // Build one complete frequency-domain chain.
 input, restored, count, 1, rate, // Use the previously allocated single-channel blocks.
 shdr_examples::frequency_filter::band_pass, // Keep a selected frequency band.
 80.f, 180.f, 20.f); // Set lower edge, upper edge and transition width in hertz.
band.processing_pipeline().run(); // Use run(gpu) to execute the same bindings on the GPU.

The modes are low_pass, high_pass, band_pass and band_stop. Low-pass and high-pass use the first cutoff. Band modes also use the second cutoff, which must be larger. Rates must be positive and finite. Cutoffs lie strictly between zero and half the sample rate.

The mask folds bins onto frequency magnitude: frequency = min(bin, count-bin) * rate / count. Equal gain at positive and negative frequencies preserves conjugate symmetry for real inputs. Bins remain in unshifted order.

With zero transition width, low-pass includes its cutoff bin, high-pass excludes it, and band-pass keeps lower < frequency <= upper. Band-stop uses the complement. A positive width centers a raised-cosine transition on each cutoff. Half the transition lies below the cutoff and half above it. The gain at a single cutoff is one half. Overlapping transitions can reduce the gain inside a narrow band.

spectrum() exposes the forward result, and filtered_spectrum() exposes the masked result as const buffer references. Bind them directly to another GPU stage to avoid a download. Calling data() reads them on the host and can wait.

This is circular block filtering: the FFT treats each block as periodic. For linear streaming convolution, provide padding and an application-controlled overlap method. The application owns overlap history and joins the filtered block edges.

Window a block before spectral analysis

The optional signal_window_kernel has input_values and output_values complex buffer ports, with block_length, channels and mode unsigned uniforms. Mode zero is rectangular, one is Hann, two is Hamming and three is Blackman. Attach signal_window.cpp when using it.

The windows are symmetric over length-1 intervals, with unit gain at length one. The same gain multiplies both complex components. Windowing reduces edge discontinuities and changes amplitude. An inverse FFT reconstructs the windowed input. Apply amplitude calibration appropriate to the chosen window and analysis.

Design and run a FIR filter

signal_filters.hpp provides filter_mode::low_pass, high_pass, band_pass, band_stop and moving_average for coefficient design. Attach signal_fir.cpp to the target.

#include "examples/signal_filters.hpp" // Use the FIR and biquad helpers.

const unsigned samples_per_channel = 1024; // Set the batch length.
shdr::buffer<float> real_input(samples_per_channel); // Fill this buffer with real signal samples.
shdr::buffer<float> filtered(samples_per_channel); // Keep output separate from input.
shdr_examples::fir_pipeline low_pass( // Design and bind one causal FIR filter.
    real_input, filtered, samples_per_channel, 1, // Process one channel.
    shdr_examples::filter_mode::low_pass, // Select a low-pass response.
    1000.0, 100.0, 0.0, 65); // Use 1000 Hz sampling, 100 Hz cutoff and 65 taps.
low_pass.processing_pipeline().run(); // Or run(gpu) for the same filtering operation.

fir_coefficients(mode, rate, cutoff1, cutoff2, taps) returns a coefficient array. The designer uses a Hamming-windowed sinc with an odd tap count from 3 to 4095. Cutoff units match the sample rate, normally hertz. Band edges increase and stay below Nyquist. Moving-average mode returns equal weights for the requested tap count.

Low-pass, moving-average and band-stop designs have unit DC gain. High-pass has unit Nyquist gain, and band-pass has unit gain at its band midpoint. The designed filters are linear-phase with group delay (taps-1)/2 samples. More taps cost more work and generally provide a narrower transition.

Each output is the causal sum sum(coefficients[k] * input[n-k]), with zero-valued samples before the batch start. Each independent batch produces exactly the input sample count. Supply preceding samples and any desired convolution-tail padding explicitly when processing stream chunks, or choose the stateful biquad helper.

A custom coefficient vector may have any length from 1 to 4095 and must contain finite values. fir_pipeline copies it into its public coefficients buffer. Editing that buffer preserves the original tap count. bind(input, output, count) changes the batch resources while retaining the channel count and coefficients. Input and output must use distinct storage.

Keep biquad state across stream chunks

The biquad designer supports low-pass, high-pass, band-pass and band-stop. Band-stop is a notch response. These coefficients follow the W3C Audio EQ Cookbook, based on Robert Bristow-Johnson's formulas.

shdr_examples::biquad_pipeline stream_filter( // Allocate zero-initialized channel state.
    real_input, filtered, samples_per_channel, 1, // Bind the first single-channel block.
    shdr_examples::filter_mode::low_pass, // Choose the recursive response.
    1000.0, 100.0); // Use a 100 Hz cutoff at 1000 Hz sampling.
stream_filter.processing_pipeline().run(); // Process the first block on the CPU.
shdr::buffer<float> next_input(samples_per_channel); // Fill this with the next block of samples.
shdr::buffer<float> next_output(samples_per_channel); // Reserve the next block's results.
stream_filter.bind(next_input, next_output, samples_per_channel); // Preserve history while replacing the buffers.
stream_filter.processing_pipeline().run(); // Continue with the retained state.

biquad_coefficients(mode, rate, frequency, Q) returns five floats in the order b0, b1, b2, a1, a2, with a0 normalized to one. The frequency is a cutoff for low-pass and high-pass, or a center for band-pass and notch. It lies strictly inside Nyquist. Q must be positive and finite. The default is approximately 0.7071. Band-pass has unit center-frequency gain. The designer rejects feedback coefficients that lose strict stability after conversion to float.

Each channel stores one vec2(z1, z2) in the public state buffer. The shader uses transposed direct-form II. It processes samples sequentially within a channel and processes independent channels in parallel. State survives later bind calls and CPU/GPU transitions. Separate streams need separate state.

reset_state() discards the old history and clears the host state. It avoids downloading old GPU history. The next GPU dispatch uploads the cleared state. The public coefficients buffer can be edited, but new feedback values must still describe a stable filter.

The signal example compares FFT results with an independent double-precision DFT, checks inverse reconstruction, and tests impulses, tones and seeded random inputs. Filter checks cover all modes, coefficient gains, double-precision convolution or recurrence references, multiple channels, explicit resets and variable stream chunks, including CPU/GPU transitions.

Keep helper objects alive while their pipeline references are used. Prepare GPU pipelines before a processing loop, and use separate writable resources for concurrent jobs. The asynchronous guide explains ring slots and completion polling. The earlier spectral example uses a direct two-dimensional DFT for small images. The FFT helper here handles batched one-dimensional signals.