Making engine, wind and tire sounds with Web Audio and no sound files
Nov 1, 2026 Β· Art and sound Β· νκ΅μ΄
This site's sound effects never had sound files. Short sounds like scoring, a hit or game over are all made on the spot with the Web Audio API. Turn on one oscillator (a sound source that plays a set waveform), raise and lower its volume quickly, and stop it about 0.2 seconds later. By changing only pitch and waveform, I made thirteen of these sounds.
That wasn't enough for Highway Driver. A racing game needs sound that plays the whole time you drive. The engine has to rise with speed and drop when you shift gears, the wind has to grow louder as you go faster, and the tires have to squeal on hard turns. I could have played a recorded engine sound at varying playback speeds, but I decided to synthesize it, like the short sounds.
What I used from Web Audio
The Web Audio API builds a graph by wiring nodes together. Sound starts at a node that produces it, passes through nodes that change it, and reaches the speakers. This post uses only a few kinds.
AudioContext: where all nodes are created and time is kept. One is shared across the page.currentTimeis the audio clock (in seconds), anddestinationis the speakers. It may start out paused (suspended), so it's woken withresume()when there's input.OscillatorNode: a sound source that keeps playing a set waveform.typepicks a sine (sine), square (square), sawtooth (sawtooth) or triangle (triangle) wave, andfrequencysets the pitch (Hz). Afterstart()it plays untilstop(). A sine wave is pure; sawtooth and square waves have many harmonics and sound rough and bright.AudioBufferandAudioBufferSourceNode: an array holding sound samples, and the node that plays it. It can be filled from a sound file, but here it's filled directly with random values from -1 to 1 to make white noise. Withloopon, it repeats forever.BiquadFilterNode: a filter that cuts or keeps certain frequencies. Withtypeset tolowpass, only sound belowfrequencypasses; withbandpass, only sound nearfrequencypasses.Qis sharpness: high values emphasize the area near the cutoff or narrow the passband, low values let a wide range through.GainNode: a node that multiplies the volume. Withgainat 0 it's silent, at 1 it's unchanged. It sets each sound's share when mixing, and it's also used to switch everything off and on.AudioParam: values likefrequency,gainandQthat can change over time. You can set.valuedirectly, butsetTargetAtTime(target, startTime, timeConstant)approaches the target smoothly along an exponential curve. Another node's output can also be connected to anAudioParam. Connecting a slow oscillator to aGainNode'sgainmakes the volume wobble at that rate. This is called an LFO (low-frequency oscillator).
Nodes are joined with connect(). connect() returns the node it connected to, so a chain fits on one line.
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const amp = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 110;
filter.type = 'lowpass';
filter.frequency.value = 800;
amp.gain.value = 0.1;
osc.connect(filter).connect(amp).connect(ctx.destination);
osc.start();
// wobble the volume 8 times a second with an LFO
const lfo = ctx.createOscillator(), depth = ctx.createGain();
lfo.frequency.value = 8;
depth.gain.value = 0.05;
lfo.connect(depth).connect(amp.gain);
lfo.start();
That's enough for a continuous "vroom" of a filtered sawtooth wave with a wobbling volume. The engine, wind and tire sounds below are all this same combination with different ingredients and numbers.
A shared feature for sounds that keep playing
Game code doesn't use the browser's audio directly; it only goes through the api the site provides. That way the site can silence things when sound is turned off, the game is paused, or you switch tabs. Short sounds end quickly, so that was never a problem, but continuous sounds are different. During a pause the game loop stops and no longer updates the sound, so unless the site turns it off, the engine keeps running on the pause screen.
So I added a new shared feature, api.hum. One sound is built like this:
- Ingredients: several oscillators and, if needed, white noise (a 2-second buffer of random values on loop)
- One filter (
BiquadFilterNode): passes only low sound (lowpass) or one band (bandpass) - Wobble: a slow oscillator (LFO) shakes the volume
- Output volume: where the site drops it to 0 for mute, pause and tab switches
A game picks the ingredients once to create a sound, then passes pitch, filter frequency, volume and wobble every frame.
const engine = api.hum({
voices: [
{ type: 'sawtooth' },
{ type: 'sawtooth', mult: 1.007, gain: 0.8 },
{ type: 'square', mult: 0.5, gain: 0.5 },
],
filter: 'lowpass', q: 4,
});
// every frame
engine.set({ freq, cutoff, gain, wob, wobFreq });
Values aren't changed instantly; setTargetAtTime makes them follow the target over a short time. If values jumped in steps every frame, you'd hear clicking. On the site side, each sound's last volume is remembered and restored when sound is turned back on or the game resumes. On restart and exit, all nodes are stopped.
set({ freq, cutoff, gain }, smooth = 0.03) {
const t = audio.currentTime;
if (freq != null) for (const v of voices) v.osc.frequency.setTargetAtTime(freq * v.mult, t, smooth);
if (cutoff != null) filter.frequency.setTargetAtTime(cutoff, t, smooth);
if (gain != null) { volume = gain; gate(); }
},
gate() { // called on mute, pause and tab switch too
const open = soundOn && !paused && !document.hidden;
out.gain.setTargetAtTime(open ? volume * 0.08 : 0, audio.currentTime, 0.04);
},
Engine: two sawtooth waves and a square wave
The engine is made of three oscillators: a sawtooth wave, a second sawtooth 0.7% higher, and a square wave half an octave down (0.5Γ). Two sounds at almost the same pitch drift slightly against each other and beat, which thickens the sound. The square wave supports the low end. Sawtooth and square waves are harsh with all their harmonics, so a lowpass filter cuts the highs. The filter's Q is raised to 4 to slightly emphasize the area around the cutoff.
The engine's state is a single value, rpm, between 0 and 1. Three things move together with it.
- Base pitch: from 70Hz to 280Hz
- Filter frequency: from 500Hz to 2600Hz. The sound brightens as the revs rise
- Volume: from 0.54 to 0.9
The wobble shakes the volume by 0.15 at half the base pitch. That's where the rattle of the exhaust comes from.
Shifting up drops the revs
Turning speed straight into pitch makes a sound that only rises, like a siren. To sound like an engine it needs gears. Gear boundaries are set as speed ratios, and within a gear the revs rise from 0.5 to 1. Shifting to the next gear drops the revs back to 0.5. Only first gear starts from an idle of 0.18.
// gear boundaries as speed / 260 km/h (top speed rises to 420, so 6th and 7th exist)
const GEARS = [0, 0.16, 0.33, 0.52, 0.74, 1.0, 1.3, 1.62];
function rpmOf(v) {
let g = 0;
while (g < GEARS.length - 2 && v >= GEARS[g + 1]) g++;
const lo = g === 0 ? 0.18 : 0.5; // idle only in first gear
return Math.min(1.05, lo + ((1 - lo) * (v - GEARS[g])) / (GEARS[g + 1] - GEARS[g]));
}
In this game the top speed rises by 20km/h at each checkpoint, from 260km/h up to 420km/h. So there are also 6th and 7th gears above 260km/h. Accelerating, the sound rises and drops over and over, climbing in "vroom, vroom" steps. A log of the pitch values showed it rising to about 280Hz and dropping to about 183Hz at every gear.
Wind and tires from white noise
The wind uses only white noise, no oscillator. Passing white noise through a bandpass filter leaves one band, which sounds like a "whoosh". Q is kept low at 0.7 for a wide band. As speed rises the band moves up (500Hz + km/h Γ 3), and the volume grows with the square of speed (0.6 Γ (km/h Γ· 420)Β²). It's barely audible when slow and swells as you speed up.
The tire squeal mixes white noise with a 1300 to 1500Hz triangle wave and feeds it to a bandpass passing 2300 to 2800Hz. Every frame the pitch and filter frequency jitter randomly within those ranges, and the volume shakes hard 17 times a second. That shaking is what gives the feel of tires sliding and scraping the road. The squeal plays under the same conditions as the tire smoke: braking while driving fast on the road, or turning for more than 0.2 seconds at speed.
Listening with the first values, the squeal was buried under the engine and barely audible. At first the filter Q was 6, which made the passband narrow, and the volume was low. Logging the actual output volumes while braking showed 0.045 for the engine and 0.028 for the squeal. I lowered Q to 3 to widen the band and made it about 4 times louder. I also lowered the engine to 0.55Γ while the squeal plays. In the same situation the engine is now 0.028 and the squeal 0.118, so the squeal is louder than the engine. Rather than only making the squeal louder, the engine briefly steps aside.
At the end, and at the very start
When time runs out, the car slides to a stop over 1.8 seconds before the results appear. The sound fades to 0 over 1.6 seconds, so the results screen is quiet.
Browsers don't let a page make sound as soon as it opens. Because of autoplay policies, sound can play only after a tap or key press. This game shows the car idling at the start line before you begin, and that idle sound is heard only after the first input.
Checking sound without listening
The screen can be checked by reading text, but sound can't. So in the browser I wrapped AudioParam.prototype.setTargetAtTime and collected the pitch and volume the game passes into an array.
const log = [];
const orig = AudioParam.prototype.setTargetAtTime;
AudioParam.prototype.setTargetAtTime = function (value, time, k) {
log.push({ value, time });
return orig.call(this, value, time, k);
};
Whether the pitch rises and drops with each gear, whether the volume goes to 0 on pause and comes back on resume, and how loud the engine and squeal are while braking were all checked from this log. The numbers above came from it. The numbers were checked with the log; whether it sounded good was decided by actually listening.
What was good about synthesis instead of sound files
- No files to download. All three sounds are a few dozen lines of code. The shared
api.humis about 70 lines, and the game's sound code is under 20. There's no waiting to download or decode sound files when the game opens, and no need to share the size budget for art with sound. - Pitch, brightness and volume move separately. With a recording, changing playback speed changes pitch and length together, and its brightness stays as recorded. In synthesis, pitch, filter frequency, volume and wobble are all separate values, so each can be computed from speed and gear. The sudden rev drop on a gear change is a single number.
- No seams. An oscillator keeps going without a break until stopped. There's no matching the end to the start as with looping a recording.
- Easy to fix. When the squeal couldn't be heard, I didn't re-record or look for a new file. I changed three numbers: Q, volume, and how much the engine is lowered.
- No licenses to check. This site uses outside art and sound only under CC0 or CC BY, which allow commercial use, and credits them. Sounds synthesized here need no such check.
- Smooth changes. Values passed with
setTargetAtTimechange on the audio side in step with the audio clock. Even though the game passes values once per frame, the sound changes without steps.
Of course it isn't as convincing as a recorded engine. Being made from a few waveforms, it keeps something of an old game console. For a game that needs to sound like a real car, a recording is better.
References
- MDN, Web Audio API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
- MDN, OscillatorNode: https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode
- MDN, BiquadFilterNode: https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode
- MDN, AudioParam.setTargetAtTime(): https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime
- MDN, Autoplay guide for media and Web Audio APIs: https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay
Games from this post
More posts
- Releasing the stone with momentum: a day of fixing Trebuchet Nov 3, 2026
- A jelly character that moves with five shapes instead of bones Nov 1, 2026
- Making a 3D-looking racing game on a 2D canvas with pseudo 3D Nov 1, 2026
- Rolling a 3D die without a library and stopping on the face you want Nov 1, 2026
- The board flashed on iPhone every time a card moved Nov 1, 2026
