@remotion/media-utils: Speed up the FFT behind visualizeAudio() - #10045
Merged
JonnyBurger merged 1 commit intoAug 1, 2026
Conversation
fftFast() built a Hamming window and then overwrote every element it had written, evaluated cos/sin of a per-stage constant four times per butterfly, and allocated two arrays per butterfly. Removing the dead window, hoisting the per-stage trig, using flat Float64Array buffers and advancing the bit-reversal index incrementally makes it 6.63x faster at N=512, with bit-identical output across 485,446 Object.is comparisons.
Contributor
Contributor
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Removed dead Hamming window — computed then immediately overwritten by bit-reversal permutation, so its removal has zero effect on output.
- Combined bit-reversal with first FFT stage — the s=1 butterfly has twiddle factors of exactly (1, 0) for all j=0, making fusion safe; traced through N=2/4/8 to verify.
- Switched computation to
Float64Arraybuffers — replaces[number, number][]tuple arrays with flat typed arrays; converted back to the same public return type at the end. - Hoisted per-stage
Math.cos/Math.sincalls — evaluated once per stage instead of once per butterfly; sameangleIncrement, so no arithmetic change. - Unrolled j=0 butterfly — twiddle (1, 0) eliminates multiplications; exact for all finite values including signed zero.
- Processed butterflies in pairs — when
s < logN, two butterflies sharing the same twiddle factor are computed together, reducing loop overhead without changing operand pairs.
The twiddle recurrence (omega * (cosA, sinA)) is preserved byte-for-byte, and the butterfly loop-nest covers the same index pairs — every optimization removes work rather than substituting a formula. The author's 485K-point Object.is comparison between old and new output across a range of sizes and adversarial inputs confirms bit-identical results.
DeepSeek Pro (free via Pullfrog for OSS) (Claude Opus not used — the program covers this model; add its provider key to run your pick) | 𝕏
JonnyBurger
enabled auto-merge (squash)
August 1, 2026 06:57
Member
|
Awesome stuff! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
fftFast()is the radix-2 Cooley-Tukey transform used byvisualizeAudio({optimizeFor: "speed"}). Three things in it cost time without affecting the result.A Hamming window is built and then thrown away. The function computes
hammingWindow(N), which is NMath.cos()calls plus two N-element arrays, and applies it intoX. The bit-reversal loop on the next statement overwrites every element ofX, so the window never reaches the transform.Math.cos(angleIncrement)andMath.sin(angleIncrement)are evaluated four times per butterfly, inside the innermost loop, althoughangleIncrementis constant within a stage. For a 512-point transform that is 9,216 trig calls where 18 would do.Math.log2(N)is re-evaluated in the stage loop condition and once per index inbitReversePermutation(), and every butterfly allocates two fresh two-element arrays.getVisualization()is the only caller. Withsmoothingon (the default),visualizeAudio()calls it three times per frame, for frames n-1, n and n+1.Fix
The window is removed, the per-stage cosine and sine are hoisted out of the butterfly loop, the working buffer becomes a pair of flat
Float64Arrays, and the bit-reversal index is advanced incrementally and fused with the first stage. The twiddle factor is still produced by the same repeated complex multiplication in the same order, so the arithmetic is unchanged.Results
Bun 1.3.14, single core, best of 7 trials.
getVisualization()withoptimizeFor: "speed":fftFast()on its own:The audiogram template passes
optimizeFor: "speed"atnumberOfSamples: 64 * 4, so the 256 row is the one it hits.Output is bit-identical
Every double was compared with
Object.is, not with a tolerance:getVisualization()output at 6 sizes andfftFast()output at 13 sizes from N=1 to N=4096: 0 mismatches.This is reachable because every change removes work rather than substituting a better formula. Computing the twiddle as
Math.cos(j * angleIncrement)would be more accurate than the recurrence and would move the output, so I did not do it.Tradeoffs
optimizeFor: "speed"path. On v4 the default is"accuracy", so callers reach it by opting in; on v5 it becomes the default.cacheinvisualize-audio.tsis read on every call and never written to, so the three smoothing calls per frame recompute their FFTs every time.j = 0butterfly is special-cased because the twiddle factor there is exactly(1, 0). That substitution is exact for every finite value, and the adversarial zero vectors above were added to check the signed-zero case specifically.Verification
oxfmt src --check,eslint srcandtsgo -dall clean.packages/media-utilsships no test files, so the bit-exactness comparison above is the correctness evidence rather than a suite.