Under the Hood
Webaudio

AudioParam: sample-accurate scheduling against the audio clock

An AudioParam is not a plain number you assign to — it's a schedulable timeline that the audio thread evaluates sample-by-sample against its own high-precision clock, which is exactly why the previous lesson warned you away from nudging gain.value from a setInterval.

AudioParam: sample-accurate scheduling against the audio clock

The first lesson in this module left a loose thread on purpose. It showed gain.gain.value = 0.2 working fine for a one-time set, then warned that changing that same value repeatedly from ordinary JavaScript — a setInterval nudging it toward zero to fade a sound out — comes out jittery and coarse. The reason, it said, is that audio runs on its own dedicated thread with its own merciless ~3 ms deadline, and the main thread is too unreliable a messenger for anything that needs to change smoothly. This lesson is the payoff of that warning: the mechanism that lets you describe a smooth change once, hand it to the audio thread, and then get out of the way entirely.

That mechanism is the AudioParam. Every knob on every node — gain.gain, oscillator.frequency, filter.frequency, delay.delayTime — isn't a plain property. It's an object with an immediate .value you can read or set synchronously, and a schedulable automation timeline that the audio thread walks forward on its own, sample by sample, independent of whatever the main thread is doing at that moment.

Two ways to change a param, one of them wrong for smoothness

Setting .value directly is a real operation — it's an instantaneous jump to a new number, applied at the very next sample the render thread processes. That's fine for a one-shot change: set the initial volume, snap a filter to a new cutoff, done. What it can't do well is smooth change over time, because "smooth" means many small steps, and driving many small steps from the main thread means many round-trips through a thread that frame drops, garbage collection, and layout thrash can stall for tens of milliseconds at a time. A fade built out of sixty setInterval ticks inherits all of that unreliability.

The alternative is to stop setting values one at a time and instead schedule a plan: a sequence of automation events, each stamped with a target value and a moment in time, that you hand to the AudioParam once. The audio thread then reads that plan and produces every intermediate sample itself, on its own clock, with zero further involvement from your code.

const context = new AudioContext();
const gain = context.createGain();
gain.connect(context.destination);

// One call, describing the whole fade. No setInterval, no per-frame code.
gain.gain.setValueAtTime(1.0, context.currentTime);
gain.gain.linearRampToValueAtTime(0.0, context.currentTime + 2);

That's the entire fade. From the moment this runs, the main thread could freeze for a full second and the fade would proceed exactly on schedule, because the audio thread isn't waiting on it for anything.

The audio clock: context.currentTime

Every scheduling call needs a moment in time to attach to, and that moment is expressed against context.currentTime — a high-precision, monotonically increasing clock owned by the audio thread, measured in seconds since the context started. It is not Date.now() and it is not performance.now(). Those two track wall-clock or main-thread time and can be affected by throttling, tab backgrounding, or clock adjustments; context.currentTime advances in lockstep with the actual render quanta being produced, so it's the one clock guaranteed to line up with what the speakers are doing right now.

You essentially never schedule at context.currentTime for something that must be gapless — by the time your JavaScript call reaches the audio thread's event queue, that exact instant may already be gone. The idiom is a small lookahead: schedule slightly in the future.

const t = context.currentTime;
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.linearRampToValueAtTime(0.0001, t + 0.05); // 50ms out, safely ahead of the deadline

This lookahead pattern is also how you sequence multiple events reliably — a drum machine or step sequencer doesn't fire oscillator.start() at the instant a beat should sound, it schedules the next handful of beats a little ahead of currentTime on a timer, so the audio thread always has upcoming events queued before it needs them.

The scheduling methods

An AudioParam's timeline is built from a small vocabulary of methods, each appending one more event:

  • setValueAtTime(value, time) — an instantaneous step to value, taking effect exactly at time. This is also how you "pin" the current value before a ramp, so the ramp has a defined starting point instead of inheriting whatever was scheduled before.
  • linearRampToValueAtTime(value, endTime) — a straight-line ramp from whatever value is in effect when this event starts, to value, arriving exactly at endTime.
  • exponentialRampToValueAtTime(value, endTime) — a ramp that changes at a constant ratio per unit time rather than a constant difference, arriving at value by endTime.
  • setTargetAtTime(target, startTime, timeConstant) — an asymptotic approach toward target that never quite arrives, decaying exponentially with the given time constant. It's the natural shape for things like a synth's decay stage or smoothing out a step change, because real physical systems (a capacitor discharging, a struck string losing energy) decay this way rather than hitting a wall.
  • setValueCurveAtTime(curve, startTime, duration) — plays back an arbitrary array of values as a curve stretched over duration, for shapes none of the above can express directly.

Each of these appends to the same timeline, and they compose: a step to set a known starting value, then a ramp, then another ramp, then an asymptotic settle, all in one .gain, all resolved by the audio thread without you touching it again.

Why exponential for volume and pitch

Human perception of both loudness and pitch is roughly logarithmic, not linear — doubling the physical amplitude of a sound doesn't sound "twice as loud" to a listener, and doubling a frequency (an octave) is felt as an equal step in pitch regardless of what frequency you started from. A linearRampToValueAtTime on a gain value produces a fade that changes amplitude in equal absolute increments, which perceptually front-loads all the audible change into the first fraction of the ramp and leaves the tail sounding like it barely changes at all. An exponentialRampToValueAtTime, by contrast, changes the value by an equal ratio at every instant — which is exactly the shape that matches how the ear perceives it, so the fade sounds evenly paced from start to finish. The same reasoning is why frequency sweeps (a synth "laser" sound, a siren effect) are built with exponential ramps on oscillator.frequency rather than linear ones — a linear sweep from 100 Hz to 10,000 Hz spends almost all its audible motion in the last few hundred Hz, while an exponential sweep sounds like a smooth, even glide through the whole range.

const osc = context.createOscillator();
const voiceGain = context.createGain();
osc.connect(voiceGain);
voiceGain.connect(context.destination);

const t = context.currentTime;

// Exponential volume fade-in, perceptually even.
voiceGain.gain.setValueAtTime(0.0001, t);
voiceGain.gain.exponentialRampToValueAtTime(1.0, t + 0.3);

// Exponential frequency sweep: 100Hz up to 2000Hz, sounds like an even glide.
osc.frequency.setValueAtTime(100, t);
osc.frequency.exponentialRampToValueAtTime(2000, t + 1.5);

osc.start(t);

a-rate vs. k-rate: how often a param is actually recomputed

Not every AudioParam is evaluated with the same granularity. Params are split into two rates:

  • a-rate ("audio rate") params are recomputed at every single sample within a render quantum. oscillator.frequency and gain.gain are a-rate, which is exactly what lets you connect another audio node's output into them (audio-rate modulation, like a vibrato LFO driving frequency) and have the modulation itself be sample-smooth rather than stepping once per block.
  • k-rate ("control rate") params are recomputed only once per 128-sample render quantum — computed at the start of the block and held constant for all 128 samples in it. This is cheaper, and it's the right default for parameters that have no meaningful reason to change faster than every ~3 ms, like a BiquadFilterNode's Q.

The distinction is a deliberate cost/fidelity trade-off baked into each node's spec: a-rate where audible sample-level smoothness matters, k-rate where block-level granularity is imperceptible and the audio thread shouldn't spend cycles recomputing something 128 times for no perceptual gain.

Tying it back to the render thread

Every one of these methods does the same thing structurally: it appends an event — a value and a time — to the AudioParam's timeline, which is data the audio thread reads. Nothing about setValueAtTime or linearRampToValueAtTime "runs" on the main thread in the sense of computing samples; they just write an entry. The actual per-sample arithmetic — walking the ramp, evaluating the exponential decay, checking whether the next event's time has arrived — happens on the render thread, in lockstep with context.currentTime, exactly where the first lesson said the reliable work has to live. That's the whole trick: you move the decision of what should happen onto the main thread (call the scheduling methods once), and leave the execution of it entirely on the thread that can't be made to stutter.

Where this goes next

The AudioParam you'll schedule most often, by far, is a GainNode's .gain — because gain is both the mechanism for plain volume control and the mechanism for shaping a note's amplitude over its lifetime. Gain, mixing, and envelopes takes this scheduling toolkit and builds the classic attack-decay-sustain-release envelope out of it, and shows the second thing gain nodes do that has nothing to do with scheduling at all: summing multiple sources into a mix.

Go deeper

  • MDN — AudioParam The full method list (setValueAtTime, the ramp variants, setTargetAtTime, setValueCurveAtTime) with signatures and edge-case notes straight from the reference.
  • MDN — BaseAudioContext.currentTime Confirms currentTime's relationship to the render thread's clock and why it differs from performance.now(), which this lesson leans on for the lookahead pattern.
  • W3C spec — automation rate (a-rate / k-rate) The authoritative definition of which built-in params are a-rate versus k-rate and why, underpinning the rate distinction covered above.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What two things does an AudioParam have that a plain JavaScript property doesn't, and why does that matter for smooth audio changes?
  2. Why is context.currentTime the right clock to schedule against, and what's wrong with scheduling against Date.now() or performance.now() instead?
  3. Walk through what setValueAtTime, linearRampToValueAtTime, and setTargetAtTime each do to the shape of the automation curve.
  4. Why does exponentialRampToValueAtTime suit volume fades and frequency sweeps better than a linear ramp, mechanically tied to human perception?
  5. What happens if you call exponentialRampToValueAtTime(0, ...), and what's the standard workaround?
  6. Explain the difference between an a-rate and a k-rate param, with an example of each, and why the split exists.
  7. A sequencer schedules notes 50ms ahead of currentTime rather than exactly at currentTime. What problem is that lookahead solving?