The first version of a Web Audio jump sound usually looks like this: create an oscillator, set the type to square, schedule a frequency ramp, connect it to the destination, and start it. It works in a console test and fails in a game, for three reasons that have nothing to do with the oscillator.
The sound clicks at the start or the end. Two jumps in quick succession cut each other off. And the sound is late relative to the input, because the audio context was created on the first jump rather than reused. The recipe below addresses all three, starting from the synthesis parameters and then moving to the scheduling and polyphony that make it usable.
The Core Recipe: 400 Hz to 800 Hz, 80 ms, Square Wave
The jump itself is a short upward sweep. A widely used retro recipe gives the parameters directly: square or triangle wave, pitch envelope 400 Hz → 800 Hz over 80 ms, total duration 100–150 ms, and moderate volume because jumps happen constantly . That is one octave, which is the range that reads as a jump rather than as a blip or a power-up.
The CRI Middleware breakdown separates the jump into an impulsion (75–100 ms, pitch only slightly rising) and a motion (at least 1 second, pitch rising one octave) . For a browser jump, the single-sweep recipe is usually enough; the two-layer version is worth building when the game has variable jump height and the sound needs to scale with it.
The code below is the minimal version of the single-sweep recipe. It is deliberately not the final version; the sections after it add the click prevention and the voice management that make it usable.
function playJump(ctx) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(400, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.13);
}
Why the Sound Clicks, and the Two Places It Happens
A click is a discontinuity. When the oscillator starts at full amplitude, or stops while the gain is still above zero, the waveform jumps from silence to a non-zero value or back, and the ear hears that jump as a click. The Web Audio API schedules these transitions with sample accuracy, so the discontinuity is real, not a timing artifact.
The first fix is at the end. The
exponentialRampToValueAtTime() method cannot ramp
to zero, so the gain must be ramped to a very small value —
0.001 is a common choice — and then the
oscillator is stopped. The residual amplitude is inaudible,
and the stop cuts the signal cleanly. A 20 ms anti-pop fade is
the standard minimum for stopping a voice in real-time Web
Audio systems .
The second fix is at the start. An oscillator that begins at full amplitude produces a click even with a clean release. The standard solution is a very short attack ramp — 1–5 ms — from near-zero to the target gain. The attack is short enough that it does not soften the transient, but long enough that the waveform does not jump.
A working envelope for a jump therefore has three stages, not two: a 1–5 ms attack from near-zero to full gain, a short sustain or hold, and an exponential decay to 0.001 before the oscillator stops. The pitch ramp runs independently and should finish before the gain reaches its minimum, so the sweep is audible for its full duration.
AudioContext Is Expensive: Create Once, Resume on Interaction
The second failure is latency, and it usually comes from
creating a new AudioContext for every sound.
AudioContext creation is expensive, browsers limit
the number of contexts, and creating contexts triggers garbage
collection . The fix is a singleton:
one context per page, created once and reused.
There is a second part to the fix. Browsers suspend an
AudioContext until a user gesture, so the context
must be resumed on the first click or keypress. The pattern is
a resume() call inside the same event handler that
starts the game, not a call at module load time.
class AudioService {
static #ctx = null;
static getContext() {
if (!this.#ctx) {
this.#ctx = new (window.AudioContext || window.webkitAudioContext)();
}
return this.#ctx;
}
static async ensureResumed() {
const ctx = this.getContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
return ctx;
}
}
The Web Audio API for browser games guide covers the broader context lifecycle; the jump-specific point is that a context created on the first jump adds its creation time to the first jump’s latency, which is exactly the moment the player notices.
Polyphony: A Jump Fires Faster Than You Think
The third failure is cut-off. If every jump creates a new oscillator but the game does not manage the voices, rapid jumps can overlap in ways that either clip the output or create a dense stack that sounds like a single broken event. The fix is a small voice pool with a hard cap.
For a jump, a cap of 2–3 simultaneous instances is usually enough. The first jump plays; the second jump, if it arrives before the first has finished, plays alongside it; the third is either allowed or steals the oldest voice depending on the design. A voice-stealing implementation with a 20 ms anti-pop fade is the standard pattern for real-time games .
The pool does not need to be large, because a jump is short and the player rarely presses jump more than two or three times per second. A pool of four oscillators with a simple round-robin or oldest-first steal is enough for a platformer, and it keeps the voice count predictable.
The Full Function, With the Fixes Applied
The version below combines the recipe, the click prevention, and the voice management. It assumes a singleton context and a small pool, and it uses an exponential pitch ramp with a short attack.
const MAX_JUMP_VOICES = 3;
const activeVoices = [];
function playJump(ctx) {
if (activeVoices.length >= MAX_JUMP_VOICES) {
const oldest = activeVoices.shift();
oldest.gain.gain.cancelScheduledValues(ctx.currentTime);
oldest.gain.gain.setValueAtTime(oldest.gain.gain.value, ctx.currentTime);
oldest.gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.02);
oldest.osc.stop(ctx.currentTime + 0.03);
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = 'square';
osc.frequency.setValueAtTime(400, now);
osc.frequency.exponentialRampToValueAtTime(800, now + 0.08);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.linearRampToValueAtTime(0.3, now + 0.003);
gain.gain.setValueAtTime(0.3, now + 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
osc.connect(gain).connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.14);
const voice = { osc, gain };
activeVoices.push(voice);
osc.onended = () => {
const index = activeVoices.indexOf(voice);
if (index > -1) activeVoices.splice(index, 1);
osc.disconnect();
gain.disconnect();
};
}
The onended handler is the part that prevents a
memory leak. Without it, the voice objects stay in the array
after the sound has finished, and the pool fills up with dead
entries. The handler removes the voice and disconnects the
nodes, which is the Web Audio equivalent of freeing a voice.
The 3 ms attack is short enough to preserve the transient and long enough to avoid a click. The pitch ramp ends at 80 ms, before the gain begins its final decay, so the sweep is fully audible. The decay reaches 0.001 at 130 ms, and the oscillator stops at 140 ms, which gives the exponential ramp time to settle without leaving an audible tail.
What Changes When the Jump Is Not a Single Sweep
The function above is for a simple jump. A game with variable jump height, a double jump, or a wall jump needs more than one sound, and the browser implementation has to handle the additional scheduling.
For variable jump height, the pitch range and the duration
should be driven by the jump’s actual height, not fixed. The
CRI implementation uses an AISAC curve to map jump height to
pitch and release time . In the browser, the equivalent is to
pass the jump’s target height into playJump() and
use it to scale the end frequency and the stop time, rather
than hardcoding 800 Hz and 140 ms.
For a double jump, the second sound needs a different contour, not just a higher pitch. The double jump guide covers the structural differences; in code, that usually means a second function with a different pitch shape and a different voice pool, so the two sounds do not steal each other’s voices.
For a wall jump, the sound is a composite of an impact and a
whoosh, and the whoosh’s pan should follow the wall normal.
The wall jump guide
covers the design; in Web Audio, the pan is a
StereoPannerNode with its pan
parameter set from the wall direction, and the impact and
whoosh are scheduled with a short offset between them.
A Browser-Specific Check Before the Sound Ships
The sound that works in a desktop Chrome tab can fail in a mobile browser or in a WebView. The checks below catch the failures that are specific to the browser environment.
-
Trigger the jump before the first user
gesture. If the sound does not play, the context
is suspended. The fix is the
resume()call on the first interaction, not a louder sound. - Trigger ten jumps in two seconds. If the sound cuts off or distorts, the voice pool is too small or the gain is summing above 1.0. The fix is a voice cap and a master gain below unity.
- Listen to the start and end of a single jump on headphones. A click at either end is a discontinuity. The fix is the attack ramp at the start and the exponential decay to 0.001 at the end.
- Check the sound on a phone speaker. A jump that starts below 400 Hz loses its sweep on a small speaker. The fix is to move the starting frequency up, not to increase the volume.
The phone speaker guide covers the frequency side of the last check; the browser context adds the first three, which are specific to Web Audio and do not appear in a native engine.