# Chapter 9: Audio (/guides/handmade-web-games/audio)





There are three ways to play sounds on the web, each with tradeoffs in latency, file size, and control.

1. The [`HTMLAudioElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLAudioElement) <small className="opacity-75">\[MDN]</small>, which streams audio from a url for playback as soon as enough data has buffered. This is best used for background music because most of the audio can download during playback rather than forcing a long loading time up front.
2. An [`AudioContext`](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext) <small className="opacity-75">\[MDN]</small> with preloaded audio samples. This is ideal for short samples and sound effects where precise timing and low-latency are critical. Sound effects tend to be short enough that preloading them all up front is practical.
3. An `AudioContext` using [oscillators](https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode) <small className="opacity-75">\[MDN]</small> or manually buffered bytes to build a virtual synth, which you can then use for both music and sound effects. It requires generating audio entirely in code, which adds significant complexity. In most cases, exporting samples from a dedicated audio program is both easier and more flexible.

## `HTMLAudioElement` [#htmlaudioelement]

<Scrollycoding preview="audio">
  <slot>
    <>    </>
  </slot>

  <slot path="steps">
    You can stream audio from any url using the HTML Audio Element. If you add code like this to an HTML file, as long as the `src` points to an actual audio source you'll get a working mini player.
  </slot>

  <slot path="steps">
    With a reference to this element's DOM node, you can control it programmatically.
  </slot>

  <slot path="steps">
    The same result can be achieved without needlessly querying the DOM by using the `new Audio(..)` constructor.
  </slot>
</Scrollycoding>

This is the simplest way to get sound into your game, but it comes with a few caveats:

1. **The audio isn't preloaded.** The browser streams audio data as the track plays rather than downloading it all up front. And that's not always a bad thing. For background music, it's a better experience to load audio during playback than to force a very long wait time up front.
2. **Playback timing is imprecise.** `.play()` queues audio playback and returns a promise that resolves when the browser is ready to play, rather than playing instantly and synchronously. With this API there will always be some lag.
3. **Playback requires a mouse or keyboard event to start.** Gamepad inputs unfortunately don't qualify. If you want music to begin as soon as the game starts, the most reliable approach is a dedicated <kbd>Play</kbd> button triggered by a mouse click or keypress. Once that first interaction unlocks audio, subsequent sounds can play freely.

## `AudioContext` [#audiocontext]

To play audio without latency, it needs to be pre-buffered. This means:

1. You need to fetch the raw bytes from the URL where the audio file is stored.
2. Once downloaded, those bytes need to be decoded for playback.

```ts title="main.ts" twoslash
// @filename: assets/audio/shoot.wav.d.ts
declare const url: string;
export default url;
// @filename: main.ts
declare const gameState: { audioLoaded: boolean };
// ---cut---
// when using Vite, imports like this resolve to a url string
import shootAudioUrl from "./assets/audio/shoot.wav";

// this provides the API to decode and work with audio data
const audioCtx = new AudioContext();

async function loadAudio(url: string) {
  // fetch the audio from the given url
  const response = await fetch(url);
  // then decode the data so that it can be played
  const buffer = await audioCtx.decodeAudioData(
    await response.arrayBuffer(), // raw bytes
  );
  return buffer;
}

const soundCache: Record<string, AudioBuffer> = {};

// run this as soon as you want to begin loading audio
loadAudio(shootAudioUrl).then((buffer) => {
  // optionally save the buffer to a cache here
  soundCache["shoot"] = buffer;
  // or update the game state to indicate loading success
  gameState.audioLoaded = true;
});
```

<Scrollycoding preview="audio">
  <slot>
    <>    </>
  </slot>

  <slot path="steps">
    With the decoded audio loaded, we can then play it.
  </slot>

  <slot path="steps">
    The `playbackRate` property speeds up or slows down the sample. A value of `2` plays at double speed (and an octave higher). Here's one-quarter speed:
  </slot>

  <slot path="steps">
    <>
      Similar to `playbackRate`, the source node has a `detune` property (in [cents](https://en.wikipedia.org/wiki/Cent_%28music%29) <small className="opacity-75">\[Wikipedia]</small>). Randomizing it gives every play a slightly different pitch, a common way to break up the pitches of repetitive sounds such as footsteps and impacts.

      <Callout title="Detune is still just playback speed" type="info">
        Under the hood, `detune` speeds up or slows down the audio sample, just like `playbackRate`. The
        main difference is that `playbackRate` deals in speed multiples whereas `detune` operates in
        musical units.

        If you use both together, they will stack.

        ```
        rate = playbackRate * 2^(detune/1200)
        ```
      </Callout>
    </>
  </slot>

  <slot path="steps">
    <>
      The Web Audio API wires together nodes in a graph. A **source** is where audio data enters the graph, and the **destination** is the output (your speakers). So far we've been connecting them directly:

      <Mermaid
        chart="
flowchart TD
S[source] --> D[destination]
"
      />

      To control volume, insert a `GainNode` between them:

      <Mermaid
        chart="
flowchart TD
S[source] --> G[GainNode]
G --> D[destination]
"
      />

      Its `gain.value`, between `0` and `1`, scales how loud the signal is as it passes through.
    </>
  </slot>

  <slot path="steps">
    Set `source.loop` to `true` to loop the audio until you call `source.stop()`.
  </slot>

  <slot path="steps">
    A `StereoPannerNode` sits between the source and destination just like a `GainNode`. It allows you to pan between `-1` (left) and `1` (right).
  </slot>
</Scrollycoding>

## Oscillators [#oscillators]

An oscillator takes a wave (like a sine wave) and generates a tone from it directly--without needing an audio file. We'll walk through the basics in this section, but oscillators are a deep topic, so this will only scratch the surface.

<Scrollycoding preview="audio">
  <slot>
    <>    </>
  </slot>

  <slot path="steps">
    `createOscillator()` produces a sine wave. After beginning playback with a call to `start()`, we'll also schedule a `stop()` one second later so it doesn't run forever.
  </slot>

  <slot path="steps">
    The `type` property selects the waveform: `"sine"`, `"square"`, `"sawtooth"`, or `"triangle"`. Each has a different timbre.
  </slot>

  <slot path="steps">
    `frequency` is measured in Hz. The default frequency for an oscillator node is Concert A at 440 Hz. 220 is an octave below.
  </slot>
</Scrollycoding>

For a deeper look at oscillators, wave shaping, and building a synth, see the [MDN Web Audio API guide](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API).

## Scheduling and ramps [#scheduling-and-ramps]

You may have noticed that some properties are set directly, like `oscillator.type = "square"`, but others have an intermediate object with a `value` property that gets set. For example, we previously used `oscillator.frequency.value` to set the frequency rather than just writing `oscillator.frequency = 220`. This intermediate object is an [`AudioParam`](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam) <small className="opacity-75">\[MDN]</small> and it gives us the ability to not just directly set a value, but also ramp it over time.

So you could slide between octaves, for example, with code like this:

```ts
const osc = audioCtx.createOscillator();
osc.frequency.value = 220;
osc.frequency.linearRampToValueAtTime(
  440, // new target frequency
  audioCtx.currentTime + 1, // when the ramp should complete
);
```

But you can also ramp `gain` (volume), `detune`, `pan`, `playbackRate`, `delay`, and a bunch of more advanced [filtering](https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode) <small className="opacity-75">\[MDN]</small> and [waveshaping](https://developer.mozilla.org/en-US/docs/Web/API/WaveShaperNode) <small className="opacity-75">\[MDN]</small> settings, too.

<Callout title="Ramps prevent clicking" type="info">
  In addition to being useful creatively, ramps solve a common audio problem. Abrupt shifts in
  certain audio settings cause a discontinuity that will sound like an unpleasant click or a pop
  through your speaker. Using an imperceptibly-fast ramp instead of an immediate change to the value
  fixes this problem.
</Callout>

<Scrollycoding preview="audio">
  <slot>
    <>    </>
  </slot>

  <slot path="steps">
    This poorly-implemented <kbd>Mute</kbd> button abruptly toggles the gain between 0 and 0.5. You should hear an audible popping sound when toggling.
  </slot>

  <slot path="steps">
    <>
      Replace the instant assignment with a very short ramp. We've gone with 2ms, which is far too fast to hear as a fade, but is long enough to smooth out the discontinuity.

      `setValueAtTime(currentValue, now)` anchors the current value on the timeline so the ramp has a starting point. `linearRampToValueAtTime` then glides to the target over the given duration.
    </>
  </slot>

  <slot path="steps">
    The same pattern with a longer duration becomes a fade. We also need to call `cancelScheduledValues` first to clear previous ramps.
  </slot>

  <slot path="steps">
    <>
      `gain` is not the only `AudioParam`. `frequency`, `playbackRate`, `detune`, and others are also `AudioParam`s that can be ramped the same way.

      `exponentialRampToValueAtTime` works just like `linearRampToValueAtTime`, except it follows a curve instead of a straight line. Because human hearing is logarithmic, exponential ramps often sound more even than linear ones.

      <Callout title="Cannot ramp to zero" type="warn">
        `exponentialRampToValueAtTime` throws an error if the target is `0`. It also can't start from `0`.
        So when you do need to ramp to zero, use a tiny value like `0.001` instead.
      </Callout>
    </>
  </slot>
</Scrollycoding>

## Bonus 1: Manual buffers [#bonus-1-manual-buffers]

Manual buffers give you direct access to sample values, making it easier to port well-known audio algorithms and apply standard signal processing techniques. Web Audio's oscillator API, in contrast, abstracts away the sample values you'd need for those algorithms.

Working at this level opens up techniques the oscillator API cannot match. You can play with volume by scaling buffered values. You can change a sine wave into a square wave by rounding. And any classic C audio code can be almost directly ported to JavaScript when you're just dealing with raw bytes in a buffer. This is exactly the approach [jsfxr](https://pro.sfxr.me/) takes for its web-based sound effect generator ([source](https://github.com/chr15m/jsfxr/blob/master/sfxr.js#L479-L492) <small className="opacity-75">\[GitHub]</small>).

<Scrollycoding preview="audio">
  <slot>
    <>    </>
  </slot>

  <slot path="steps">
    <>
      Filling the buffer with random values between -1 and 1 creates white noise.

      <Callout title="Warning headphones users" type="warn">
        This is a harsh, loud sound.
      </Callout>
    </>
  </slot>

  <slot path="steps">
    Here's what a 440hz sine wave looks like when manually buffered.
  </slot>

  <slot path="steps">
    You can add as many tones together as you want. To avoid clipping, scale them down after the addition. Here's an A Minor chord.
  </slot>
</Scrollycoding>

## Bonus 2: Spatial audio [#bonus-2-spatial-audio]

With headphones on, compare the following raw audio...

<div className="not-prose my-4 rounded-lg bg-fd-card p-4">
  <audio src="footstepsPreviewUrl" className="w-full max-w-full" />
</div>

...with the same clip in 3D space, positioned as though someone is walking in a circle around you, the listener:

<SpatialFootstepsCircleDemo src="footstepsPreviewUrl" />

This is possible with a `PannerNode`, the 3D counterpart to the simple `StereoPannerNode` from earlier. It positions audio in full 3D space relative to a listener, simulating how sound changes as it moves around a human head. However, this added realism comes at a significant performance cost, so 3D panning should be used where it matters most rather than everywhere by default.

MDN summarizes what's happening under the hood as:

<blockquote className="font-fancy italic">
  <q>
    basically a whole lotta cool maths to make audio appear in 3D space
  </q>

  <br />

  <cite className="opacity-75">
    \- MDN, really
  </cite>
</blockquote>

For an intuitive demo, check out this 3D [boombox demo](https://mdn.github.io/webaudio-examples/spatialization/) <small className="opacity-75">\[MDN]</small>.

<Callout title="The 3D PannerNode is good in 2D, too!" type="info">
  Just because you have access to three dimensions does not mean you have to use all three. If
  you're building a 2D game, `PannerNode` is still the right tool to use for audio
  spatialization--just ignore the Z axis.
</Callout>

The basic setup looks like this:

```ts twoslash
const audioCtx = new AudioContext();

const panner = audioCtx.createPanner();
panner.panningModel = "HRTF"; // "Head-Related Transfer Function"

panner.positionX.value = -8; // 8 units to the left
panner.positionY.value = 0; // neither above nor below
panner.positionZ.value = 1; // 1 unit in front of the listener

// you can also change the direction the audio source is pointing.
// this is not YOUR orientation, but the orientation of the sound!
panner.orientationX.value = 1; // pointing to the right
panner.orientationY.value = 0;
panner.orientationZ.value = 0;

const source = audioCtx.createBufferSource();
source.connect(panner);
panner.connect(audioCtx.destination);
```

`HRTF` stands for [Head-Related Transfer Function](https://en.wikipedia.org/wiki/Head-related_transfer_function) <small className="opacity-75">\[Wikipedia]</small>, which is used to simulate changes in sounds as they're positioned relative to a human head. These changes are deeper than simply adjusting left/right volume. Sounds in front of your head vs behind it would have identical left/right panning and yet be perceived differently. `HRTF` attempts to mimic these changes by also adjusting timbre, either boosting or attenuating frequencies in a physically realistic way. The alternative to `HRTF` is the more-efficient default value `equalpower`. Whereas `HRTF` relies on measured responses from human subjects, `equalpower` is more like stereo panning but in three dimensions.

To play audio in a circle around the listener:

```ts twoslash
declare const soundCache: Record<string, AudioBuffer>;
// ---cut---
const audioCtx = new AudioContext();
const source = audioCtx.createBufferSource();
source.buffer = soundCache["footsteps"];

const r = 4; // walk radius in meters
const n = 24; // # of segments to approximate a circle

const panner = audioCtx.createPanner();
panner.panningModel = "HRTF";
panner.refDistance = r;

const now = audioCtx.currentTime;

// as AudioParams, all position and orientation values support
// the scheduling and ramping techniques we covered earlier.
panner.positionY.setValueAtTime(0, now);
panner.positionX.setValueAtTime(0, now);
panner.positionZ.setValueAtTime(-r, now);

for (let i = 1; i <= n; i++) {
  const time = now + (source.buffer.duration * i) / (2 * n);
  const angle = (2 * Math.PI * i) / n;
  panner.positionX.linearRampToValueAtTime(r * Math.sin(angle), time);
  panner.positionZ.linearRampToValueAtTime(-r * Math.cos(angle), time);
}

source.connect(panner);
panner.connect(audioCtx.destination);
source.start();
```

There is considerably more to explore with spatial audio. We've shown how to position audio sources, but you can also position the listener, set distance scales, modify the shape of the cone of sound emanating from the source, and stack multiple 3D positioned audio sources together. For a deeper tour, see MDN's [Web Audio spatialization basics](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Web_audio_spatialization_basics).

## Bonus 3: Working around autoplay restrictions [#bonus-3-working-around-autoplay-restrictions]

Browsers prevent you from autoplaying audio without a user-initiated mouse or keyboard event, which browsers refer to as a *gesture*. But that gesture doesn't have to be the one that actually starts playback. The standard workaround is therefore to react to any gesture at all, then call `.resume()` on the `AudioContext` as soon as a supported event has fired. After that, the browser will play all subsequent audio--including sounds triggered by gamepads--without restriction.

You still won't be able to autoplay audio immediately on load, nor will you be able to rely solely on gamepad interactions to kick off playback, but this is the closest we can get to true autoplay in 2026.

Chrome has a [code snippet](https://developer.chrome.com/blog/web-audio-autoplay#moving-forward) in its developer blog that they recommend. If you intend to only have one `AudioContext` in your game, which is likely, here's a simpler alternative to the Google snippet:

```ts twoslash
const audioCtx = new AudioContext();
const abortController = new AbortController();

const interactionEvents = [
  "click",
  "contextmenu",
  "auxclick",
  "dblclick",
  "mousedown",
  "mouseup",
  "pointerup",
  "touchend",
  "keydown",
  "keyup",
] as const;

const resumeAudio = () => {
  if (audioCtx.state === "suspended") {
    audioCtx.resume();
  }
  abortController.abort();
};

// fixes this error: "The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page."
interactionEvents.forEach((eventName) => {
  document.addEventListener(eventName, resumeAudio, {
    passive: true,
    signal: abortController.signal,
  });
});
```

## Up next [#up-next]

Now that we've covered audio, haptics, and visual effects, we'll look at how to combine them to provide responsive and satisfying feedback to players. [Chapter 10 →](/guides/handmade-web-games/techniques-for-game-feel-and-juice) is about game feel and juice.

## References [#references]

* [Web Audio API guide](https://webaudioapi.com/book/Web_Audio_API_Boris_Smus_html/ch01.html) — a more technical introduction to web audio for games
* [Jsfxr Pro](https://pro.sfxr.me/) — retro 8-bit sound effects generator
* [BeepBox](https://www.beepbox.co) and [JummBox](https://jummb.us) — retro music sequencers for composing melodies and patterns
* [Web Audio spatialization basics](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Web_audio_spatialization_basics) <small className="opacity-75">\[MDN]</small> — a deeper dive on web audio spatialization
* [HRTF](https://en.wikipedia.org/wiki/Head-related_transfer_function) <small className="opacity-75">\[Wikipedia]</small> — how audio spatialization works
* [Web Audio API best practices](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices) <small className="opacity-75">\[MDN]</small> — loading strategies, autoplay handling, and tips for `AudioParam`s
* [Web Audio, Autoplay Policy and Games](https://developer.chrome.com/blog/web-audio-autoplay) <small className="opacity-75">\[Chrome dev blog]</small> — Chrome's announcement explaining their updated autoplay policy and its impact on web games
