Skip to content

Recipes

Task-focused walkthroughs for the most common use-cases. Each one names the calls to make and the pitfalls to watch for. Each assumes the standard include/ using statements:

#include <SignalCore.h>
using namespace signalcore;

Run a spectrum analyzer

  1. Pick a transform size with a single macro that also sizes the sample buffer: #define FFT_LENGTH 512, then FFT<FFT_LENGTH> fft;. The size is a power of two between 32 and 4096. AVR is capped at 256 (see Configuration & Platforms).
  2. Select a window once in setup(): fft.setWindowType(WindowType::BlackmanHarris);.
  3. Per frame, fill a float samples[FFT_LENGTH] buffer from your ADC or a SignalGenerator, then transform and read it out:
removeDC(samples, FFT_LENGTH);       // strip DC so it can't dominate bin 0
fft.calculate(samples);              // copy + window + transform + magnitude

int   bin = fft.peakBin();           // dominant bin, DC skipped
float hz  = fft.peakFrequency(SAMPLE_RATE);

fft.postScaleDB(-100.0f, 1.0f);      // magnitudes -> dBFS, after any linear read

Warning

  • The real spectrum is N/2 + 1 bins (result[0 .. N/2], DC to Nyquist). Everything above that is stale. fft.peakBin() scopes the search to that range and skips DC for you. On a bare buffer, findPeak(mags, N/2 + 1, 1) does the same and returns an absolute bin. See FFT output format.
  • To turn a bin into a frequency by hand, use hz = bin * sampleRate / N with the full FFT size N.
  • Run findPeak (and SINAD and ENOB) on the linear magnitudes first, then call postScaleDB, which overwrites the buffer with dB in place. Its maxScale is your input's full scale: 1.0f for normalized synthesis, or the counts or volts at full scale for ADC data. A wrong maxScale only shifts the dB axis. It does not move the peak bin.

Reference: the FFTSpectrumPeak example.

Run an FFT from a real ADC

Read a block of ADC counts and transform them directly. The integer overload casts to float for you, so there is no separate conversion step. On Teensy, we recommend Pedvide's ADC library for the capture itself; a SignalCore ParallelSampler is also in development.

uint16_t adcBuffer[FFT_LENGTH];

const uint32_t periodUs = 1000000UL / SAMPLE_RATE;   // pacing sets the real rate
uint32_t       next     = micros();
for (int i = 0; i < FFT_LENGTH; i++) {
    while ((int32_t)(micros() - next) < 0) { /* wait for this sample's slot */ }
    adcBuffer[i] = (uint16_t)analogRead(A0);
    next += periodUs;
}

fft.calculate(adcBuffer);                    // integer overload: counts -> float
int   bin = fft.peakBin();                   // skips DC, where the ADC bias sits
fft.postScaleDB(-100.0f, ADC_FULL_SCALE);    // dBFS against the ADC's count range

Warning

  • The integer overloads (calculate(uint16_t*) and calculate(int16_t*)) cannot remove DC in place, so the ADC's bias lands entirely in bin 0. Skipping bin 0 in the search handles it. To strip it explicitly, copy the counts into a float buffer and call removeDC() before calculate(). removeDC only operates on the float path.
  • The read pacing is your sample rate. analogRead timing sets it, and the spectrum only reaches SAMPLE_RATE / 2, the Nyquist limit. A tone above that aliases down to a lower, wrong frequency.
  • postScaleDB's maxScale is the ADC full scale in counts ((1 << bits) - 1), because the integer path leaves magnitudes in counts.

Reference: the FFTFromAnalogInput example.

Measure SINAD and ENOB

Grade a tone, or the converter that captured it, from a single transform.

removeDC(samples, FFT_LENGTH);
fft.calculate(samples);

float* mags  = (float*)fft;                       // read metrics on LINEAR magnitudes
float  sinad = SINAD(mags, FFT_LENGTH / 2 + 1);   // dB
float  enob  = ENOB(mags, FFT_LENGTH / 2 + 1);    // bits, = (SINAD - 1.76) / 6.02

Warning

  • Compute SINAD and ENOB before postScaleDB. They read the linear magnitudes that calculate() produces, and postScaleDB overwrites the buffer with dB.
  • A single non-coherent FFT understates ENOB. A tone that does not complete a whole number of cycles in the window leaks across bins and reads as noise. Choose a frequency of cycles * sampleRate / N with an integer cycles so the tone closes on itself. A coherent tone like this needs no window.
  • Pass the real-bin count N/2 + 1, not N.

Reference: the SignalQualityMetrics example.

Add an oscilloscope-style triggered trace

  1. Create a trigger: Trigger trig(TriggerMode::RisingEdge);. With no threshold it auto-thresholds at the signal midpoint.
  2. Capture into a buffer larger than the output window, roughly 1.5 times the size (CAPTURE_N > OUTPUT_N), so the search has headroom on both sides of the edge.
  3. Per frame, align and render:
float* view = trig.calculateOffset(buffer, OUTPUT_N, CAPTURE_N);
if (trig.valid()) {
    // draw view[0 .. OUTPUT_N - 1]: a phase-stable, edge-aligned frame
}

By default the edge sits at the center of the frame. Call trig.setPosition(0.25f) to move it left and keep more pre-trigger history.

Warning

  • Always gate rendering on trig.valid(). When no edge is found, calculateOffset returns the unaligned buffer, so an ungated draw shows a jittery frame.
  • If you set a fixed threshold with setThreshold(), the trigger skips its automatic min/max scan and the signal range keeps its default of 0 to 3.3 (volts) — call trig.setMinMax(lo, hi) in that case, because the hysteresis margin is derived from that range. With the auto-threshold (no setThreshold() call) the range is re-scanned on every call and setMinMax() is unnecessary.
  • The hysteresis latch persists between calls. Call trig.reset() (or trig.begin(...)) when the signal source changes.

Reference: the EdgeTriggeredFrame example.

Generate a test signal in software

No analog hardware is required. Synthesize a waveform and read it sample by sample.

SignalGenerator sig(SAMPLE_RATE);

void setup() {
    sig.setCarrier(SignalShape::Sine, 1000.0);   // 1 kHz tone
    sig.setAM(SignalShape::Sine, 7.0, 30.0);     // optional: 30% AM at 7 Hz
    sig.setNoise(3.0);                            // optional: 3% gaussian noise
}

void loop() {
    float s = (float)sig.nextSample(0);          // 0 = full-resolution float
    // feed s into an FFT buffer, print it, etc.
}

Warning

  • Call setCarrier() before the first nextSample(). The default carrier frequency is 0, which produces no phase advance and so a silent, flat output.
  • AM and noise amplitudes are percentages, not linear gains. setNoise(3.0) means 3 percent, not 3 times.
  • nextSample(0) returns the raw bipolar float carrier. Any non-zero argument N returns something else entirely: an unsigned N-bit code in [0, 2^N) with a half-scale DC offset baked in, ready for a DAC — removeDC() matters again if you FFT that output.

Reference: the SignalGeneratorBasic and EMASmoother examples.

Smooth or max-hold a spectrum

Both processors run on the FFT magnitude buffer, frame by frame. Size them to the number of real bins, N/2 + 1.

ExponentialMovingAverage ema(FFT_LENGTH / 2 + 1, 0.2f);
PeakHoldProcessor        hold(FFT_LENGTH / 2 + 1, 0.95f);

// per frame, after fft.calculate(...):
ema.update((float*)fft);                       // smoothed spectrum
hold.update((float*)fft, FFT_LENGTH / 2 + 1);  // per-bin max-hold
float* smoothed = ema;
float* bars     = hold;

Warning

  • sampleWeight (EMA) and decay (peak-hold) are opposite conventions. sampleWeight is the weight on the new sample, so a higher value tracks faster and smooths less. decay is the fraction of the old peak kept, so a higher value falls off more slowly. Do not copy one constant into the other.
  • Both processors accept an optional input length on update() and clamp to min(inputSize, size). The single-argument form assumes the buffer holds at least size() elements — prefer the two-argument form when the input length can vary.
  • Both take their memory from the heap when constructed. On RAM-tight boards such as AVR, construct them once early in setup() to avoid fragmentation.

Reference: the SpectrumSmoothingPeakHold example, which contrasts the two processors on a live swept spectrum.

Enable CMSIS hardware FFT on a non-Teensy ARM board

Teensy 4.x uses CMSIS-DSP automatically. Other ARM boards (Arduino Giga, STM32, SAMD51) can opt in:

#define SIGNALCORE_FORCE_CMSIS
#include <SignalCore.h>

Warning

  • The define must appear before the include.
  • If the core does not ship arm_math.h at all, the build now stops with a clear #error naming the macro. If the core ships the header but does not link the CMSIS-DSP objects, you still get link errors for arm_rfft_fast_* — the define alone does not pull the library in.
  • No other code changes are needed. The generic and CMSIS backends produce the same output, bin for bin.

Choose a window function

Set the window once with fft.setWindowType(WindowType::…). The choice trades main-lobe width (frequency resolution) against sidelobe suppression (the ability to see a small tone next to a large one).

Window Good for
Rectangle / None Transient capture; maximum frequency resolution, worst leakage
Hann A sensible general-purpose default
Hamming General use; a slightly different sidelobe shape than Hann
BlackmanHarris Separating closely spaced tones (deep sidelobes); the example default
FlatTop When amplitude accuracy matters more than frequency resolution
Welch, Triangle, Nuttall, Blackman, BlackmanNuttall Intermediate trade-offs

Print the active window on a serial readout with getWindowName(wt), or getWindowName(wt, true) for the short label.

Compare windows / measure leakage

To see the trade-off rather than just pick from the table, run one captured tone through several windows and compare where the energy lands. Place the tone deliberately off-bin (for example 1234.5 Hz) so the leakage is there to measure.

void analyzeWindow(WindowType win) {
    fft.setWindowType(win);
    fft.calculate(samples);                  // windows its own internal copy
    float* mags    = (float*)fft;
    int    peakBin = fft.peakBin();          // dominant bin, DC skipped

    // Worst sidelobe: the largest magnitude outside a guard band around the peak.
    float sidelobe = 0.0f;
    for (int i = 1; i <= FFT_LENGTH / 2; i++) {
        if (abs(i - peakBin) <= 2) continue;
        if (mags[i] > sidelobe) sidelobe = mags[i];
    }
    Serial.print(getWindowNameAbbr(win));    // then peakBin and 20*log10(sidelobe)…
}

Warning

  • fft.calculate() windows a private copy of the input, so the shared samples buffer survives intact. Set a window, transform, and repeat, all from the same capture.
  • Leakage only shows when the tone sits between bins. On an exact bin center (cycles * sampleRate / N) even a Rectangle window leaks nothing, which hides the whole lesson.
  • The main lobe has skirts of its own. Exclude a guard band (±2 bins here) around the peak so they are not mistaken for a sidelobe.

Reference: the WindowComparison example.


Created by VoidLoop · Founded by Gregory Kovacs · Written by Zachariah Magee