The classic platformer jump sound is often described as a stylistic choice, but it started as a hardware constraint. The NES’s 2A03 audio processor had two pulse channels with adjustable duty cycle and a hardware sweep unit. It could generate a square wave and slide its frequency up or down within a note. That is exactly the vocabulary a jump needs: an upward slide from a bright, hollow waveform.
Modern browsers have no such constraint. The Web Audio API can generate any waveform at any frequency and ramp it however you like. That freedom is useful, but it also makes it easy to overcomplicate a sound that worked precisely because it was simple. The sections below rebuild the classic jump from its hardware origins, then show the equivalent in a browser, and finally explain which parameters actually matter when you change them.
What the NES Hardware Actually Gave the Sound: Duty Cycle and Sweep
The NES pulse channels support four duty cycles: 12.5%, 25%, 50%, and 75% . The 75% setting sounds identical to 25% because a square wave’s timbre depends on the absolute duty cycle relative to 50%, not on which side is “high.” A 12.5% duty cycle produces a thin, nasal, “angry” tone; a 25% duty cycle is slightly fuller; a 50% duty cycle is the full, hollow square wave most people associate with the console.
The hardware also includes a sweep unit that moves the pitch up or down during the note. The NES documentation describes sweep as useful for “sirens, zaps, and retro pitch bends,” and the square channels can sweep upward or downward . The square channels cover a frequency range of roughly 54.6 Hz to 12.4 kHz .
That combination — a duty cycle that sets the timbre, and a sweep that sets the contour — is the whole architecture of the classic jump. There is no filter, no reverb, and no second oscillator. The character comes from two parameters.
The Modern Equivalent: OscillatorNode, Square Wave, Exponential Ramp
The Web Audio API reproduces this structure directly. An
OscillatorNode has a type property
that accepts 'square', and its
frequency is an AudioParam that can
be scheduled with setValueAtTime() followed by
exponentialRampToValueAtTime()
.
const ctx = new AudioContext();
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);
The code does four things, in order. It creates a square
oscillator. It sets the starting frequency. It schedules an
exponential ramp to the ending frequency. And it schedules an
amplitude decay that ends slightly after the pitch sweep. The
exponentialRampToValueAtTime() method is the
right choice for the pitch because pitch is perceived
logarithmically; a linear ramp from 400 to 800 spends more
time in the upper half of the interval than the ear expects
.
The amplitude ramp is also exponential, for the same reason. A linear fade from full volume to silence sounds like it lingers, because the ear’s sensitivity to amplitude is logarithmic. An exponential fade matches the way the sound actually decays.
What Each Parameter Does to the Perceived Jump
The table below is not a recipe. It is a map of what changes when you change a value, so that you can adjust the sound toward a specific character rather than guessing.
| Parameter | What it controls | Audible effect when changed |
|---|---|---|
| Duty cycle | Timbre | 12.5% is thin and nasal; 50% is full and hollow. Narrower duty cycles cut through a busy mix but lose body. |
| Start frequency | Register | Lower starts read as heavier or larger; higher starts read as lighter. The start must stay above ~400 Hz to survive small speakers. |
| Sweep range | Perceived motion | Narrow ranges read as blips; roughly one octave reads as a jump; wider ranges read as power-ups. |
| Sweep duration | Speed of the motion | Shorter durations feel snappier; longer durations feel floatier. The sweep should end before the amplitude envelope closes. |
| Amplitude decay | How long the sound is audible | Fast decay keeps the jump discrete; slow decay smears it into the next event. |
The Values That Produce a Working Jump
A documented recipe from retro SFX practice gives a specific starting point: 400 Hz to 800 Hz over 80 ms, with a total duration of 100–150 ms . That is one octave, which is the range most often cited for a jump that reads as a jump rather than as a blip or a power-up.
The NES duty cycle most often associated with the classic jump is 12.5% or 25%. A 12.5% duty cycle produces a thin, bright tone that cuts through a mix; a 25% duty cycle is slightly fuller and was also common. The 50% duty cycle is fuller still, but it can sound too round for a jump in a busy mix.
The amplitude envelope in the Web Audio example above uses a 120 ms decay from 0.3 to 0.001. That is slightly longer than the 80 ms pitch sweep, which ensures the sound does not cut off abruptly while the sweep is still in progress. The total event is around 130 ms, which sits inside the 100–150 ms range and below the 200 ms ceiling where a jump starts to overlap the next input.
What the Web Audio Version Adds That the NES Did Not Have
The browser version is not a strict emulation. It has options the NES did not, and those options are worth knowing about even if the classic sound does not use them.
Duty cycle is a continuous parameter, not a fixed
set. The NES offered four settings; the Web Audio API
does not expose duty cycle directly, but a custom
PeriodicWave can approximate any duty cycle, and
a BiquadFilter can shape the harmonics after the
oscillator. The practical effect is that you can tune the
timbre between the NES’s four steps rather than choosing one.
The sweep curve can be linear or exponential.
The NES sweep unit was a hardware counter with a fixed
behavior. The Web Audio API offers both
linearRampToValueAtTime() and
exponentialRampToValueAtTime()
. For a jump, the exponential
ramp is the closer match to the NES’s perceived behavior,
because pitch is logarithmic. A linear ramp from 400 to 800
spends too much time in the upper half of the interval.
The envelope can be shaped independently of the pitch. On the NES, the envelope and the sweep were separate hardware units but shared a frame sequencer. In the browser, the gain node’s automation is completely independent of the oscillator’s frequency automation, which means you can end the amplitude before the sweep finishes, or let the sweep continue into silence, without any constraint.
The synthesis guide covers the broader argument for synthesizing rather than recording a jump; the browser-specific point is that the Web Audio API reproduces the NES structure and then removes its limits.
The Pitfalls of the Browser Version, and How to Avoid Them
The freedom of the Web Audio API introduces failure modes the NES could not produce. Three are worth knowing before you export a WAV.
The click at the start and end. An oscillator that starts and stops instantly produces a discontinuity in the waveform, which is audible as a click. The standard fix is a very short fade — a few milliseconds — at both ends. The example above starts at 0.3 and stops after the decay has reached 0.001, which is close enough to zero to avoid a click without an explicit fade-in. For sounds that need a sharper attack, a 1–2 ms linear fade is enough to remove the discontinuity without softening the transient.
The exponential ramp cannot reach zero. The
Web Audio API’s exponentialRampToValueAtTime()
requires a target value greater than zero. Passing 0 throws
an error. The workaround is to ramp to a very small value —
0.001, as in the example — rather than zero, and to stop the
oscillator immediately after. The residual amplitude is
inaudible, and the stop cuts the signal cleanly.
The offline render must match the real-time
context. If the sound is rendered with
OfflineAudioContext for export, the sample rate
and the scheduled times have to match what you tested in
real time. A 44.1 kHz offline render of a sound that was
designed at 48 kHz will have a different duration in samples,
and the sweep will end at a slightly different point relative
to the envelope. The fix is to pin the sample rate in the
offline context to the rate the game will use, and to design
at that rate from the start.
The phone speaker guide covers the frequency side of the same issue: a jump designed below 400 Hz will lose its sweep on a small speaker, regardless of how clean the Web Audio implementation is.