I was writing a frequency sweep (a chirp, in DSP terms), a tone that starts at a low pitch and slides up to a high one, like the sound a theremin makes when you move your hand slowly. Mine was supposed to go from 20 Hz (a deep rumble) to 20 kHz (the upper edge of hearing) over three seconds. Simple enough: at each instant in time, figure out what frequency the tone should be at, and play that.
Except it did not work. The pitch climbed at twice the slope it should have, and by the end of the three seconds it had overshot to roughly 40 kHz instead of landing at 20. The first sample was correct. Every sample after that was wrong, and the error grew over time.
The cause turned out to be a piece of math that looks trivial in the constant-frequency case but is sneakily different once the frequency starts moving. The fix is small, but the insight behind it changed how I think about sine waves in general. The punchline: is a better mental model of a tone than the textbook , and here’s why. Along the way I’ll line the problem up next to a car driving down a road, because the exact same mistake on the linear side would be obvious to anyone.
From the Hifi project, a Zig audio experiment. The relevant code is src/processor/sweep.zig in kaiwalya/hifi (pinned to commit 0600cc0, lines 42–49 show the fixed integral).
The analogy, up front
This whole post is really one idea, dressed up in two costumes. On the left is a car moving in a straight line. On the right is a sine wave, which is a point moving around a circle. In both cases there is a rate (speed for the car, , in m/s; frequency for the wave, , in Hz) and an accumulation of that rate over time (distance for the car, , in meters; phase for the wave, ). Both setups share a duration () and a time variable ().
A quick unit-choice note. I’ll measure phase in cycles (also called turns): one full rotation around the circle equals 1. This keeps the formulas clean. Converting back to the radians that wants is a one-line bookkeeping step at the very end; it doesn’t affect any of the reasoning along the way. Think of it like setting in relativity to keep the physics readable.
With that, every step of the argument shows up in both columns, because the math is identical.
The setup
A car drives in a straight line. At any instant, it has a speed in m/s. The odometer reads the total distance traveled, in meters.
Speed is the rate of change of distance:
Equivalently, distance is the accumulation of speed.
A sine wave is a point moving around a unit circle. At any instant, the angle advances at some rate we call the frequency, , in Hz (cycles per second). The total angle so far is the phase , in cycles.
Frequency is the rate of change of phase:
Equivalently, phase is the accumulation of frequency.
The easy case: constant rate
A car on cruise control at speed . After seconds, the odometer reads:
Speed times time gives distance. This is the formula everyone internalises before they learn any calculus.
A pure tone at a constant frequency . After seconds, the phase has advanced:
Frequency times time gives phase. A 440 Hz tone has accumulated 440 cycles after one second.
The rate starts varying
Now the car is accelerating. Pick the simplest case: linear acceleration from to over duration :
The speedometer needle sweeps from up to over seconds.
Now the frequency is changing. The Hifi sweep ramps linearly from (20 Hz) to (20 kHz) over (three seconds):
The pitch slides from a low hum to a high whine over seconds.
The tempting shortcut, which is wrong
Here is where the trap springs. In both worlds, you have a varying rate and you have the constant-case formula fresh in your head. The tempting move is to just substitute: wherever the constant rate appeared, plug in the current value of the varying rate.
“I’m going 60 mph and I’ve been driving for 2 hours, so I’ve gone 120 miles.”
If you started at 20 and accelerated up to 60, this is wrong. You did not drive 120 miles; your average speed was lower than 60, so the actual distance is less. Multiplying current speed by elapsed time overstates the distance.
“The constant-tone phase is ; I’ll just put the varying in place of .”
Same mistake, same reason. The phase overstates how far around the circle you’ve actually gone. The pitch you hear is not , and the sweep arrives at a higher frequency than .
The product rule makes the error explicit
What does the shortcut actually compute? Differentiate its output and see what rate you’d have had to be going to produce that accumulation.
Take the derivative of the wrong to see what “apparent speed” this implies:
That second term is extra. With linear acceleration, , so is exactly the ramp portion of , added a second time. The shortcut acts as if you’d been accelerating twice as aggressively as you actually were.
Take the derivative of the wrong phase to recover the heard frequency:
Same structure. With the linear sweep, , so is the ramp portion of added a second time. The heard pitch ramps at double the intended slope.
The and terms are the cost of the lie. Intuitively: “current rate times elapsed time” retroactively rewrites history. It treats every earlier moment as if it had happened at the current rate, not the actual (lower) rate it had at the time. The faster the rate is changing, the bigger the lie.
The fix: integrate
A real odometer does not multiply. It adds up every little bit of distance as it happens. That is an integral, and in code it is a running accumulator (a phase accumulator, on the sine side). When the rate is constant the integral collapses to a multiplication; when the rate varies, you have to actually do the sum.
The first term is the “cruise control” distance: what you’d get if the car had held its starting speed the whole time. The second term is the correction for the acceleration, quadratic in , with the famous factor that shows up in for uniform acceleration.
The first term is the “cruise control” phase: what you’d get from a constant tone at . The second term is the correction for the sweep, quadratic in , with the same factor. Not a coincidence: it is the same as the car’s, because the math is literally the same.
Sanity-check either column by differentiating the accumulation and recovering the rate cleanly. The product rule does not bite, because there is no product.
And the waveforms. Same , , , same intention, different reality:
Why this is easy to miss (on the radial side)
On the linear side, nobody makes this mistake. Every physics student learns for uniform acceleration. “Speed times time” for a changing speed is obviously wrong the moment you write it down. The car’s odometer is a concrete object you can point at.
On the radial side, the textbook formula for a pure tone hides the structure. The inside is already a disguised integral, just one where the integrand happens to be constant so the integral simplifies to a product. Generalising by swapping for preserves the shape of the formula but silently changes the operation from “evaluate a degenerate integral” to “multiply two things.” The step was invisible when the rate was constant. When the rate varies, that hidden step is the whole problem.
The mental model I kept
From this point on I stopped thinking of a sine wave as . That form is a trap: it hard-codes a degenerate integral and looks like a multiplication, and when you generalise the thing you thought you were multiplying, you generalise wrong. I replaced it with:
Framed this way, the question “what sample do I emit at time ?” stops being a math problem about multiplying and . It becomes a bookkeeping problem: track the phase. At every moment, the phase advances by whatever the current frequency is. Constant tone, sweep, vibrato: the machinery is the same. You maintain a running and you take its sine. The sweep gotcha cannot occur because never enters the picture. You never multiply a rate by an elapsed time at all.
In code, this is usually a phase accumulator (the same idea that sits at the heart of a numerically controlled oscillator, or NCO): one variable that increments by each sample, and stays in . Conceptually, it is an odometer for the sine wave.
Side by side, the two versions look almost the same. One line changes:
sin(2π f t)
for each sample at time t:
f = f_min + (f_max - f_min) * (t / duration)
output = sin(2 * pi * f * t)
It reads fine. Every line is individually correct. The bug is only visible once you ask what f * t means when f has been changing the whole time.
sin(2π θ(t))
phase = 0
for each sample at time t, step dt:
f = f_min + (f_max - f_min) * (t / duration)
phase += f * dt
output = sin(2 * pi * phase)
The quantity f * t has vanished. Phase is a running sum of tiny increments, each taken at whatever the frequency happened to be at that moment.
The Hifi version takes a closed-form shortcut, using the known integral of a linear ramp rather than a per-sample accumulator, but the idea is identical. See src/processor/sweep.zig (pinned to commit 0600cc0). The comment above the formula even spells out the derivation: ng_disp = integrate ng_v.
See the main Hifi post for the surrounding architecture: SIMD vectors, the processor graph, why Zig for audio.