Files
skills/docs/media/audio/audio-analysis-beat/src/algorithms/spectral-flux.md
2026-05-29 08:20:56 +01:00

4.2 KiB

algorithms/spectral-flux

Purpose

Spectral-flux onset detection via a hand-rolled radix-2 real FFT. This module provides the highest-precision ODF in the package — it catches timbral onsets (hi-hat over a sustained pad, a fingertip pluck on a held note) that the energy ODF cannot see, because it measures which bins are changing rather than how much total energy is moving.

It is written entirely in terms of Float32Array and Float64Array and never touches the browser AnalyserNode or any Node-only API. The same code runs identically under Bun, Node.js, Deno, and every browser.

Features

  • fftRadix2(re, im) — in-place Cooley-Tukey complex FFT for power-of-two sizes. The lowest-level primitive; both arrays are read and written.
  • magnitudeSpectrum(frame, fftSize) — real-input single-sided magnitude spectrum with a Hann window applied before transform; returns length fftSize/2 + 1.
  • spectralFlux(frame, prevMag, fftSize) — single-frame ODF value; sum of positive bin-to-bin differences. prevMag is mutated to become the current magnitude spectrum so the caller can chain across frames.
  • spectralFluxOdf(mono, fftSize, hop) — convenience that splits a mono sample stream into hop-sample frames of fftSize samples (zero-padded as needed) and returns the full ODF.

Usage: Basic Example

import { spectralFluxOdf } from "@suaveplan/audio-analysis-beat/algorithms/spectral-flux";

const mono = new Float32Array(48_000 * 4); // 4 seconds of audio
// ... fill mono with samples ...
const odf = spectralFluxOdf(mono, 1024, 512);
console.log(odf.length, "frames"); // ceil(192000 / 512) = 375

Usage: Advanced Example

import {
	magnitudeSpectrum,
	spectralFlux,
} from "@suaveplan/audio-analysis-beat/algorithms/spectral-flux";

const fftSize = 2048;
const hop = 512;
const buf = new Float32Array(fftSize);
const prev = new Float64Array((fftSize >> 1) + 1);
const mono = new Float32Array(48_000 * 10); // 10 seconds

// Manual frame loop — lets the caller stop early on an AbortSignal,
// thread per-frame work through other code, or thin the ODF on the fly.
const odfValues: number[] = [];
for (let start = 0; start + fftSize <= mono.length; start += hop) {
	buf.set(mono.subarray(start, start + fftSize));
	odfValues.push(spectralFlux(buf, prev, fftSize));
}

// Verify the magnitude spectrum has the expected shape on its own.
const probe = magnitudeSpectrum(mono.subarray(0, fftSize), fftSize);
console.log(probe.length === fftSize / 2 + 1);

API Reference

  • fftRadix2(re: Float64Array, im: Float64Array): void — re.length must equal im.length and must be a power of two; transform is in place.
  • magnitudeSpectrum(frame: Float32Array, fftSize: number): Float64Array — output length fftSize/2 + 1; Hann-windowed.
  • spectralFlux(frame: Float32Array, prevMag: Float64Array, fftSize: number): number — returns the flux value; prevMag is mutated in place to become the current magnitude spectrum.
  • spectralFluxOdf(mono: Float32Array, fftSize: number, hop: number): Float64Array — returns empty array when any argument is empty or non-positive; odf[0] is forced to zero so peak-picking does not flag the artificial first-frame onset.

Implementation Notes

The FFT is a textbook iterative Cooley-Tukey radix-2 with explicit bit-reversal permutation followed by log2(n) rounds of butterflies. Twiddle factors are computed by complex multiplication of a running unit-magnitude phase rather than a precomputed table — for the FFT sizes used in this package (<= 4096) the table cost is not worth it, and trig recurrence avoids the alignment subtleties of typed-array tables.

Only positive bin-to-bin differences contribute to the flux. Negative differences (bins that are quieter than in the previous frame) carry no perceptual onset information and including them would partly cancel the positive contributions, blurring peaks.

prevMag is initialised to zeros, which means the first frame's flux is the sum of its full magnitude spectrum — an artefact, not a real onset. The spectralFluxOdf wrapper therefore overwrites odf[0] to zero before returning so peak-picking does not flag that boundary.