Chapter 3 of 7. Prerequisite: Chapter 2, where you isolate a bass band and drive a value with it. Now you have a bass number that jumps on every kick, and the question is which primitive turns it into motion. Reach for the wrong one and a fast kick either lags or bounces past its target.
The advice you will read everywhere is that audio-reactive animation should use interpolate(), not spring(). That advice is correct, and almost nobody explains why, so people distrust it. A bass pop feels like it wants physics. It hits, then it recoils and settles. That is a spring. So they wire the bass value into a spring, watch it overshoot and wobble on fast kicks, and conclude Remotion is fighting them.
Remotion is not fighting them. spring() and interpolate() take fundamentally different inputs, and the bass number is the wrong input for one of them. This chapter is the distinction, the values, and the one place in this build where a spring is actually correct.
What each primitive takes as its input
The whole confusion collapses once you look at what you are allowed to pass in.
interpolate() maps a range of values to another range. You hand it a number, an input range, and an output range, and it returns the mapped number. Its input can be anything you hand it: a frame, a scroll offset, a bass amplitude. The interpolate() docs describe it plainly as mapping "a range of values to another using a concise syntax." It holds no state. Frame 40 does not remember frame 39. Call it with the same inputs and you get the same output, every time.
spring() works differently. Its input is the frame number, and only the frame number. It simulates a physical spring settling from 0 to 1 over time, and you read where the mass is at the current frame. The spring() docs make the time-basis explicit: "The spring animation starts at frame 0, so if you would like to delay the animation, you can pass a different value like frame - 20." You do not pass a spring your bass value. You pass it a frame, and it gives you a physically eased progress between 0 and 1.
That is the crux. A spring animates from one state to another over time. Your bass signal is a fresh reading every single frame, already carrying its own shape from the audio, so it is not a state transition at all. Feeding a per-frame reading into a system built to interpolate between two fixed endpoints over a duration is asking the wrong tool the wrong question.
visualizeAudio to a bass value, then interpolate() not spring()
Here is what the reactive path actually looks like in this build. Every reactive composition samples the audio twice per frame, at 32 bins, once smoothed and once raw:
const smooth = visualizeAudio({ fps, frame, audioData, numberOfSamples: 32, smoothing: true });
const raw = visualizeAudio({ fps, frame, audioData, numberOfSamples: 32, smoothing: false });
smoothing: true is an energy-decay envelope. It carries a little of the previous frame's energy forward, so the value blooms up and breathes down instead of snapping. smoothing: false is the raw transient with no memory, so a kick reads as an instantaneous spike. This is the same idea as AnalyserNode's smoothingTimeConstant on the web, where the AnalyserNode interface provides "real-time frequency and time-domain analysis information" and lets you average against the last frame. You get a physics-like settle for free, from the analysis layer, before any animation primitive is involved.
The bass bands come off those two arrays as fixed index slices with hand-tuned gain:
sub = clamp(avg(smooth, 0, 3) * 3.2); // bins 0-2, sub-bass envelope, the breath
kick = clamp(avg(raw, 0, 2) * 3.6); // bins 0-1, raw transient, the punch
sub is the slow bloom, kick is the sharp hit. Now watch how they drive the picture. They do not go into a spring. They go into inline scalar arithmetic:
const scale = kb + sub * 0.02 + kick * 0.015;
const brightness = 1 + sub * 0.22 + kick * 0.12;
kb there is the Ken Burns push, itself a plain interpolate() over time: interpolate(frame, [0, durationInFrames], [1.06, 1.16], { extrapolateRight: 'clamp' }). The bass reactions are added on top as scalar terms. No spring anywhere in this path. The kick contributes kick * 0.015 to scale and kick * 0.12 to brightness, and because kick is the raw transient, that contribution appears and vanishes on the exact frame the kick lands. That instantaneous response is the entire point. A spring would smear it across its settle time and blunt the hit.
Where the one spring() in this build actually lives
So where is the single spring? On the brand lockup entrance. The logo settling into frame at the start of the composition:
const intro = spring({ frame: frame - 8, fps, config: { damping: 200 } });
const introY = interpolate(intro, [0, 1], [unit * 0.03, 0]);
This is textbook correct spring usage, and it is worth reading closely because it demonstrates the division of labour.
intro is a spring driven by frame - 8. It is time-triggered. It starts settling eight frames into the composition and does not care about the audio at all. It produces a physically eased progress from 0 to 1. Then introY feeds that progress into interpolate() to map the 0-to-1 spring output onto a pixel range, from a small offset down to zero. The spring owns the feel of the motion over time. interpolate() owns the range it plays out across. That pairing, spring for eased time-progress and interpolate to map it to real units, is the standard Remotion idiom.
Notice what this entrance is. It is a one-shot transition from a start state to an end state over a fixed number of frames. That is precisely the job a spring exists to do, and precisely what a per-frame bass reading is not.
Tuning mass, stiffness and damping to kill overshoot
Now the part nobody writes down. When you do use a spring, and you want it not to overshoot, which values.
Remotion's spring() config has three physical parameters plus a clamp. The lockup above sets only one:
config: { damping: 200 }
Everything else falls back to Remotion's defaults: mass is 1, stiffness is 100, and overshootClamping is false. Those three defaults produce a lively, slightly bouncy spring. It settles past its target and comes back. That bounce is charming on a playful entrance and wrong on almost anything reacting to a beat, which is exactly why the build overrides damping.
Here is what each knob does, and the direction to turn it:
dampingis resistance. Higher damping bleeds energy out faster, so the spring settles without oscillating. The default10bounces. Pushing it to200, as the lockup does, gives a heavily damped, effectively no-overshoot settle. If your spring wobbles, this is the first value to raise.stiffnessis how hard the spring pulls toward its target. Higher stiffness reaches the target sooner and, on its own, more aggressively, which tends to increase overshoot. Lower stiffness is a lazier, softer approach.massis inertia. Heavier mass is slower to start and slower to stop, and a heavy mass with low damping overshoots harder because there is more momentum to absorb. Lighter mass is twitchier and settles quicker.
The combination that avoids overshoot is high damping relative to stiffness and mass. The damping: 200 on a default mass: 1 and stiffness: 100 is exactly that. If you would rather guarantee no overshoot regardless of the physics, Remotion gives you overshootClamping: true, which hard-clamps the value so it can never pass its target. That is the blunt instrument. Tuning damping up is the tasteful one, because it decelerates naturally instead of hitting a wall.
Which output the bass drives matters as much as the primitive
Once the primitive question is settled, a second one decides whether the visualiser reads as a piece of film or as a demo: which output the bass value lands on.
Look again at the reactive grade in this build, and read the coefficients rather than the variable names:
const scale = kb + sub * 0.02 + kick * 0.015;
const brightness = 1 + sub * 0.22 + kick * 0.12;
The geometry channel barely moves. kb runs the Ken Burns push between 1.06 and 1.16 across the whole shot, and the bass adds at most a percent or two on top. You would struggle to see sub * 0.02 as motion on its own. The reaction you actually register lives in brightness, where the kick's kick * 0.12 term swings the exposure on every hit, and in the celestial bloom, whose radial gradient carries the sub band directly in its alpha:
background: `radial-gradient(58% 42% at ${raySource[0] * 100}% ${raySource[1] * 100}%,
rgba(232,196,106,${0.22 * glow}), transparent 62%)`,
mixBlendMode: 'screen',
glow is sub-driven, so the bass modulates the opacity of a screen-blended light source sitting over the photo. When the bass swells, the frame gets more lit. The music changes how the picture is lit rather than pushing an element around inside it, and that is the version that stops reading as a demo.
The reasoning is worth carrying past this build. When something visibly moves in response to audio, the viewer clocks the mechanism, and a legible mechanism reads as a demonstration of the mechanism. Driving a light or opacity channel instead hides the wiring, so the response registers as a change in the image rather than a visible tween. The output register you choose earned more here than any easing curve did.
There is a spring-versus-interpolate decision hiding inside that, because the smoothing flag sets the temporal shape of whatever you drive. The sub band feeding the bloom comes off the smoothed array, so it swells and settles across phrase-length gestures instead of stabbing on each kick. Drive that same bloom off the raw transient and it would flicker on every beat. Same signal, two smoothings, because the bloom wants a slow shape while a hard-cut effect wants the fast one. Pick the shape the effect needs first, and the primitive and the smoothing flag follow from it.
The rule to carry out of this chapter
For anything that reacts per-frame to audio, use interpolate() or plain scalar arithmetic on the value. It holds no state and stays deterministic, so a kick appears on the exact frame it lands and nowhere else. The bass reactions in this build are scalar terms added onto an interpolate()-driven Ken Burns push, and the kick is the raw, unsmoothed transient precisely so the punch is instantaneous.
Reach for spring() only when something transitions from one state to another over time and you want that transition to feel physical, such as a title entering the frame or a logo settling into place. There, tune it. Raise damping toward 200 to stop the overshoot, keep stiffness and mass near their defaults unless you have a reason, and pair the 0-to-1 spring output with interpolate() to map it onto real units.
If you want a bass hit to feel springy, do not spring it. Shape it with smoothing: true for the bloom or a decay envelope for the settle, and drive the value straight. The physics you were reaching for lives in the envelope, not in spring().
Chapter 4 takes the value you now trust and confronts the thing that quietly ruins every music video, keeping the audio locked to the picture when the render pipeline is trying to drift them apart.
One spring for the entrance, arithmetic for the beat
Add a title or logo entrance to your own visualiser using a single spring driven by an offset frame value, with damping raised high enough that it settles without overshoot, and map its 0 to 1 output onto a pixel offset with interpolate. Keep every audio-reactive channel as plain scalar arithmetic on the band values. Then break it on purpose: temporarily wire the kick band into a spring-driven property, watch what happens on fast hits, and revert.
Expected behaviour
- The entrance settles into place with no visible bounce past its resting position
- The entrance starts after a deliberate delay achieved by subtracting a frame offset from the current frame
- Kick-driven properties respond on the exact frame a hit lands, with no settle tail afterwards
- The deliberately broken spring-on-kick version visibly smears or wobbles compared with the scalar version
PROVE IT Step frame by frame through one kick in both versions and show that the scalar version peaks on the hit frame while the spring version is still on its way.
Your entrance spring settles past its target and wobbles back. Which config value do you raise first?
The brand lockup spring delays its start by passing the current frame minus how many frames?
In your own words, why is a per-frame bass reading the wrong input for spring()?
Show answer
spring() takes only the frame number and simulates a one-shot physical transition from 0 to 1 over time, whereas the bass value is a fresh reading every frame that already carries its own shape from the audio. It is not a state transition between two endpoints, so feeding it into a spring smears the hit across the settle time and blunts the punch.
↺ re-read: “What each primitive takes as its input”