The Web Audio API is not a game engine audio system. It is a low-level graph of nodes that you connect together and schedule with precise timing. For short, parameter-driven effects like a coin sound, that is actually a good fit: the sound is two oscillators with an envelope, and the API gives you exact control over when each note starts.
This article builds a complete coin sound from scratch in JavaScript. It assumes the sound design decisions are already made, and those decisions come from the coin sound breakdown in How to Create a Coin Sound Effect: two notes, the second higher than the first, both short, with a small gap between them. Everything here is about implementing that shape in code.
The audio graph is the part that is easy to get wrong. The rest is straightforward once you understand how nodes are connected and how the timing works.
The Audio Graph for a Coin Sound
A coin sound in the Web Audio API is two short tones played in sequence. Each tone is an oscillator connected to a gain node, and the gain node is connected to the audio destination. The gain envelope is what turns a continuous tone into a short blip.
The graph for a single note looks like this: oscillator, then gain, then destination. The oscillator produces the tone, the gain shapes its volume over time, and the destination is the speakers.
For a coin sound, this graph is created twice, with a small delay between the two so that the second note starts after the first. Because the Web Audio API uses scheduled timing, the delay is not a JavaScript timer; it is an exact offset applied to the start time of the second oscillator.
This distinction matters. A setTimeout call
introduces unpredictable jitter depending on the main
thread's load. Scheduling on the audio context's clock is
sample-accurate, which is what keeps the two notes in a coin
sound tightly aligned.
Setting Up the AudioContext
The AudioContext is the entry point for all Web Audio work. It represents the audio processing graph and owns the clock that scheduling depends on.
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
Browsers require a user gesture before an AudioContext can produce sound. Creating it at the top level of a script is fine, but it will not play anything until the user has clicked, tapped, or pressed a key. In a game, this is usually handled by resuming the context on the first input.
function resumeAudioContext() {
const ctx = getAudioContext();
if (ctx.state === 'suspended') {
ctx.resume();
}
}
Calling resumeAudioContext() from the first
click handler is enough to unlock the context for the rest
of the session. Without it, every sound will fail silently
on modern browsers.
Building a Single Note
A note is an oscillator with a gain envelope. The oscillator produces a fixed waveform at a fixed frequency, and the gain node ramps the volume from full to zero over the note's duration.
function playNote(frequency, startTime, duration, volume) {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(frequency, startTime);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(volume, startTime + 0.005);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + duration + 0.02);
}
Three things are worth noting here.
First, the gain starts at zero rather than at the target
volume. This prevents the click that occurs when an
oscillator begins at full amplitude. The
linearRampToValueAtTime moves the gain up to
the target over a very short time, roughly 5 milliseconds,
which is short enough that the note still has a sharp attack
but long enough to avoid the click.
Second, the decay uses
exponentialRampToValueAtTime rather than a
linear ramp. Exponential decay matches the way physical
sounds fall off, and it is what makes the note feel like a
short blip rather than a chopped-off tone. The target value
of 0.001 is used instead of 0 because an exponential ramp
cannot reach zero.
Third, the oscillator's stop time is slightly
longer than the envelope's end. This ensures the envelope
finishes before the oscillator is stopped, which avoids any
audible truncation.
Adding the Second Note
The coin sound is two calls to the same function, with the second call scheduled a few tens of milliseconds after the first. The frequency of the second note is higher than the first, and its duration is slightly longer.
function playCoinSound() {
const ctx = getAudioContext();
const now = ctx.currentTime;
const firstNote = 988; // B5
const secondNote = 1319; // E6
const firstDuration = 0.06;
const secondDuration = 0.12;
const gap = 0.05;
playNote(firstNote, now, firstDuration, 0.25);
playNote(secondNote, now + firstDuration + gap, secondDuration, 0.25);
}
The two frequencies are a fourth apart, which is the interval the coin sound design article recommends as a starting point. B5 and E6 are one common pair, but any two frequencies a fourth to a fifth apart will produce a recognizable coin.
The gap of 50 milliseconds is what separates the two notes so the ear reads them as a phrase rather than a single rough tone. Smaller gaps blur the notes together; larger gaps turn the coin into a melody. Anything between 40 and 80 milliseconds works for most game styles.
Why Scheduling on the Audio Clock Matters
The ctx.currentTime value is the audio clock's
current position in seconds. It advances independently of
JavaScript execution, and it is the reference that all node
scheduling uses.
When you call osc.start(startTime) with a time
in the future, the browser schedules the node to begin at
exactly that position in the audio stream. This is
sample-accurate, which means the two notes in the coin sound
will always be separated by the same precise interval,
regardless of what the main thread is doing.
This is the reason to avoid setTimeout for
scheduling the second note. A setTimeout of 110
milliseconds might fire at 105, or at 130, depending on the
browser's event loop. On the audio clock, it fires at exactly
the scheduled time.
The pattern is to compute all the start times relative to
ctx.currentTime at the moment the sound is
triggered, then pass those times to the scheduling
functions. This keeps the entire coin sound locked to a
single coherent timeline.
Variation and Repeated Playback
A single coin sound played dozens of times during gameplay becomes noticeable in the wrong way. The standard fix, as described in the coin sound design article, is to vary the pitch slightly across pickups.
With the Web Audio API, variation is trivial. Before scheduling the two notes, apply a small random multiplier to both frequencies. The same multiplier on both preserves the interval, which keeps the sound recognizable while shifting its overall pitch.
function playCoinSound() {
const ctx = getAudioContext();
const now = ctx.currentTime;
const pitchShift = 1 + (Math.random() - 0.5) * 0.06;
const firstNote = 988 * pitchShift;
const secondNote = 1319 * pitchShift;
playNote(firstNote, now, 0.06, 0.25);
playNote(secondNote, now + 0.11, 0.12, 0.25);
}
A pitch shift of plus or minus 3 percent is enough to break the repetition without making the coin sound like a different effect. Larger shifts start to feel like the sound is unstable rather than varied.
For a faster effect, the same pattern can be applied to the volume of the coin, or to the gap between the two notes. Small variations in either parameter keep the sound alive over long play sessions.
Cleanup and Node Lifecycle
Oscillator nodes in the Web Audio API are one-shot. Once they are started and stopped, they cannot be restarted. Every coin sound creates two new oscillators and two new gain nodes, which are garbage collected after they finish.
This is normal and not a cause for concern in a game with occasional coin pickups. If the game generates hundreds of coin sounds per second, the allocation overhead becomes relevant, but that is not a typical scenario.
One thing to watch for is a node that is created but never
started. This can happen if an exception occurs between
creating the oscillator and calling start. A
defensive pattern is to wrap the sound in a try-catch, or to
build the entire node graph and schedule it in one
uninterrupted block.
Also remember to disconnect nodes once they have finished. The Web Audio API does not automatically clean up references in the graph. A node that is connected to the destination keeps its connections alive until it is explicitly disconnected or the graph is closed.
osc.onended = function () {
osc.disconnect();
gain.disconnect();
};
This is a small optimization, but it prevents a long-running game session from accumulating disconnected nodes.
What Changes for a Different Pickup
The same structure produces a wide range of pickup sounds with small parameter changes. The coin is one configuration of the two-note pattern, and other collectibles are variations on it.
- Higher-value coin. Widen the interval between the two notes, or raise the base pitch. A fifth or an octave sounds more celebratory than a fourth.
- Gem or rare pickup. Use a triangle wave instead of a square, and lengthen the second note. The softer waveform reads as more valuable and less arcade.
- Retro coin. Keep the square wave and narrow the durations. The 8-bit sound design principles apply directly to this variant.
- Modern UI confirmation. Use a sine wave, shorten the gap, and reduce the interval to a minor third. The sound becomes a polite acknowledgment rather than a reward.
Each variant is the same graph with different parameters. The code structure does not change, which makes it easy to generate an entire pickup sound set from a single function.
Exporting the Sound as a WAV File
Sounds generated with the Web Audio API can be recorded and
exported as WAV files using an
OfflineAudioContext or a
MediaStreamDestination. The offline approach
renders the sound faster than real time and produces a
AudioBuffer you can convert to a WAV blob.
The reason to do this is that a game engine like Unity or Unreal cannot call your JavaScript directly. If the coin sound needs to be used in a traditional engine, the JavaScript version is useful as a prototyping tool, but the final sound is still a WAV file.
For browser-based games, this step is unnecessary. The Web Audio API plays the sound directly, and no file needs to be exported at all.
If you want a reference WAV of the coin sound for comparison, you can generate one in the browser and export it, then load it into the same project and compare its character to the code-generated version. The two should be nearly identical if the parameters match.
Create a Coin Sound for Reference
Open the SfxMaker coin sound generator, create a two-note coin, and export it as a WAV file to compare against your JavaScript implementation.
Open Coin Sound Generator →Common Mistakes
- Starting the gain at full volume. An oscillator that begins at full amplitude produces a click. Always ramp the gain up over a few milliseconds.
- Using a linear decay instead of exponential. A linear fade sounds like a chopped-off tone. The exponential ramp is what makes the note feel natural.
-
Scheduling with
setTimeout. The main thread introduces timing jitter. Usectx.currentTimeand schedule the second note on the audio clock. - Forgetting to resume the AudioContext. On modern browsers, sounds will not play until the user has interacted with the page and the context has been resumed.
- Setting the exponential ramp target to zero. Exponential ramps cannot reach zero. Use a small value like 0.001 instead.
- Playing the same pitch every time. Even a small random shift on each play keeps the coin sound from becoming fatiguing during long sessions.
What to Check Before Shipping
Test the coin sound on the target browser and device. The Web Audio API is well supported, but the first-play latency can be noticeable on some platforms, and the AudioContext unlock behavior is stricter on mobile Safari than on desktop Chrome.
Check the sound during actual gameplay, not just in isolation. A coin sound that works once can become repetitive when the player collects a hundred of them in a minute. The pitch variation is what prevents that, and it should be tested under realistic conditions.
If the coin sound needs to work in both a browser game and a traditional engine, the JavaScript version and the WAV file should sound as close as possible. Generating the WAV from the same parameters used in the JavaScript implementation is the simplest way to keep them consistent.