#4301·FastLED

CQ_OCTAVE has no response across 24% of the frequency range it declares

Author: zackeesCreated Sep 10, 2026Updated Sep 11, 2026

Measurement

Swept 200 log-spaced sine tones (amplitude 16000, 512 samples, 44100 Hz) across the default range and recorded the best band response at each:

fl::audio::fft::Args(512, 16, 90, 14080, 44100, Mode::CQ_OCTAVE)

49 of 200 tones (24%) produce under 20 counts in every band. The dead spans:

span width
146 – 161 Hz 1.10x
170 – 183 Hz 1.08x
320 – 337 Hz 1.05x
383 – 403 Hz 1.05x
722 – 779 Hz 1.08x
1111 – 1199 Hz 1.08x
7463 – 9621 Hz 1.29x
10923 – 13383 Hz 1.23x

The two at the top are the serious ones. A 8348 Hz tone at full amplitude reads:

band:    0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
counts:  1  1  1  1  0  1  0  1  0  0  7  4  5  2  1  1

Nothing. For comparison, the same tone placed on band 13's centre (7178 Hz) reads 2410 counts in band 13.

Why

_generate_kernel in src/third_party/cq_kernel/cq_kernel.cpp.hpp builds a windowed cosine at each band centre and keeps only the FFT bins above min_val, so each kernel is a narrow sparse selector around its centre. At 16 bands over 90–14080 Hz adjacent centres are a factor of 1.40 apart, and in the top octaves the kernels are narrower than that gap — so the kernels do not tile the axis and tones between them fall through.

Confirmed it is the spacing rather than a bug in the window scaling: FastLED calls generate_kernels once per octave group (bands 3, 2, 2, 2, 2, 2, 3), each with its own fmin, so N_window = N/(f/fmin) stays in 261–512 samples and never collapses. The kernels are well formed; there are just not enough of them.

What this affects

Reaching for CQ_OCTAVE at a modest band count gives an analyser that is silent for a quarter of its own declared range, including most of 7.5–13.4 kHz. For an LED spectrum display that is a visible dead zone, not a subtle accuracy loss.

It also made a test unstable. Binning adversarial - CQ_OCTAVE monotonicity sweep stepped an arbitrary 30-point log grid, which put 2 steps inside these holes; the peak bin there was decided by margins of one count (16 versus 15 at 11502 Hz) between bands holding only noise. Any change that moves the grid a few percent — such as making fl::exp accurate, FastLED#4288 — flipped it. That case now sweeps the analyser's own band centres and carries a detection floor, so it no longer depends on the holes; this issue is the deficiency itself.

Directions, not a recommendation

  1. More bands. The holes close as centre spacing approaches kernel width. Worth measuring where that is — the same 200-tone sweep at 24, 32, 48 bands would say.
  2. Widen the kernels. min_val sets how much of each kernel is kept; a lower threshold keeps more bins and broadens the selector, at the cost of more sparse elements and more work per frame.
  3. Widen the analysis window per band. sigma in _generate_guassian is fixed at 0.5 with a comment saying smaller is more accurate; larger would trade selectivity for coverage.
  4. Refuse the configuration. If a given (bands, samples, range) cannot tile, say so at construction rather than returning silence.

I have not measured which of these is right, and (2) and (3) change the shape of every kernel, so they want their own before/after sweep.

Reproducing

The numbers above come from a 200-tone sweep driven through the public fl::audio::fft API — same shape as Binning adversarial - CQ_OCTAVE monotonicity sweep in tests/fl/audio/fft/fft.cpp, with the step count raised and the peak energy recorded alongside the peak bin.

Generated with Claude Code

https://claude.ai/code/session_01MLhWkMfzLjrnLTDMBE6Fj9