AudioContext in JavaScript: build a synth with the Web Audio API

An AudioContext is a small audio studio inside the page. You wire nodes together, schedule their volume on the context's own clock, and the browser renders the sound.

To make sound with the Web Audio API, create one AudioContext, build a chain of nodes such as an oscillator and a gain node, and connect the end of the chain to ctx.destination, the speakers.

Nothing plays until the context is running, and that needs a click.

Try it. The status line shows the context's state and its clock. Watch both change on the first click.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AudioContext: play a note</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .panel { max-width: 420px; background: #fff; border-radius: 12px; padding: 16px 18px; box-shadow: 0 4px 16px rgba(0, 0, 0, .08); }
  label { display: block; margin: 0 0 12px; font-size: 14px; }
  select, input { width: 100%; margin-top: 4px; font-size: 15px; }
  button { font-size: 16px; padding: 10px 18px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #status { margin-top: 12px; font: 13px ui-monospace, Consolas, monospace; color: #4b5563; }
</style>
</head>
<body>
<div class="panel">
  <label>Wave <select id="wave">
    <option>sine</option><option>triangle</option><option>square</option><option>sawtooth</option>
  </select></label>
  <label>Pitch: <span id="hz">440</span> Hz
    <input id="freq" type="range" min="110" max="880" value="440"></label>
  <button id="play">Play a note</button>
  <div id="status"></div>
</div>

<script>
  // One context for the whole page. It may start "suspended" until the user clicks.
  const ctx = new AudioContext();
  const wave = document.getElementById('wave');
  const freq = document.getElementById('freq');
  const status = document.getElementById('status');

  freq.addEventListener('input', () => document.getElementById('hz').textContent = freq.value);

  document.getElementById('play').addEventListener('click', async () => {
    if (ctx.state === 'suspended') await ctx.resume();  // the click unlocks audio

    const t = ctx.currentTime;
    const osc = new OscillatorNode(ctx, { type: wave.value, frequency: +freq.value });
    const gain = new GainNode(ctx, { gain: 0 });
    osc.connect(gain).connect(ctx.destination);  // oscillator -> volume -> speakers

    gain.gain.setValueAtTime(0, t);
    gain.gain.linearRampToValueAtTime(0.3, t + 0.02);  // fade in over 20 ms
    gain.gain.setTargetAtTime(0, t + 0.5, 0.05);       // fade out after 0.5 s
    osc.start(t);
    osc.stop(t + 1);  // an oscillator plays once; make a new one per note
  });

  // Show the context's state and its clock
  function show() {
    status.textContent = 'state: ' + ctx.state + '   currentTime: ' + ctx.currentTime.toFixed(2) + ' s';
    requestAnimationFrame(show);
  }
  show();
</script>
</body>
</html>
One AudioContext, a new oscillator and gain node per note. Change the wave and pitch, then play again.

This guide is about generating and shaping sound. To play an MP3 or other sound file, play sound in JavaScript covers new Audio() and its autoplay error.

Nodes, a graph and one context

The Web Audio API works like a set of patch cables. Each node does one job, and connect() joins the output of one node to the input of the next.

A tone goes through a gain node and an analyser to the speakers.
A tone goes through a gain node and an analyser to the speakers.
  • OscillatorNode makes a tone. type picks one of four wave shapes, and frequency sets the pitch in hertz.
  • GainNode multiplies the signal by its gain value. It is the volume knob, and the tool for fades.
  • AnalyserNode passes the sound on unchanged and lets script read it.
  • ctx.destination is the output device.

connect() returns the node you connected to, so a chain fits on one line:

const ctx = new AudioContext();
const osc = new OscillatorNode(ctx, { type: 'sine', frequency: 440 });
const gain = new GainNode(ctx, { gain: 0.3 });
osc.connect(gain).connect(ctx.destination);
osc.start();

Every node belongs to the context it was made with. Create the context once and reuse it; create nodes freely.

The autoplay rule: suspended until a gesture

Open the first example and read the status line before clicking. It can say suspended with the clock at 0.00. Browsers can hold a new context in that state until the visitor clicks, taps or presses a key.

The three states you deal with, and what moves a context between them.
The three states you deal with, and what moves a context between them.

A suspended context throws no error. Oscillators start, but the context's clock does not move, so there is silence. The fix is to resume it from inside a user gesture:

button.addEventListener('click', async () => {
  if (ctx.state === 'suspended') await ctx.resume();
  playNote();
});

resume() returns a promise. Awaiting it before scheduling notes means the first note is timed on a running clock. If you would rather show a label such as Sound on, listen for the context's statechange event.

When a page is completely done with sound, ctx.close() releases the resources. A closed context cannot be resumed: resume() rejects with InvalidStateError, so make a new one.

Envelopes: why notes click and how gain ramps fix it

A speaker cone follows the signal exactly. If a tone starts at full volume, or stop() cuts it in the middle of a wave, the signal jumps in one step. That jump is heard as a click.

Cut the wave and it jumps. Glide the gain down first and it fades to silence.
Cut the wave and it jumps. Glide the gain down first and it fades to silence.

The cure is an envelope: schedule the gain instead of switching it. Hold each pad below and compare. The line is the measured loudness of the output.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gain envelope vs hard start and stop</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .pads { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 480px; }
  .pad {
    height: 110px; border-radius: 12px; border: 2px solid; font-size: 15px; font-weight: 600;
    touch-action: none; user-select: none; -webkit-user-select: none; cursor: pointer;
  }
  #hard { background: #fff7f5; border-color: #f3b8a6; color: #9a3412; }
  #soft { background: #f4fbf6; border-color: #a7dbb7; color: #0f5132; }
  .pad.down { filter: brightness(.93); }
  canvas { display: block; width: 100%; max-width: 480px; height: 150px; margin-top: 12px; background: #fff; border-radius: 10px; }
  p { font-size: 13px; color: #4b5563; margin: 8px 0 0; max-width: 480px; }
</style>
</head>
<body>
<div class="pads">
  <button class="pad" id="hard">Hold: hard on/off</button>
  <button class="pad" id="soft">Hold: envelope</button>
</div>
<canvas id="scope" width="960" height="300"></canvas>
<p>The line is the loudness of the output, measured with an AnalyserNode. Listen for a click when a hard note starts and stops.</p>

<script>
  const ctx = new AudioContext();
  const analyser = new AnalyserNode(ctx, { fftSize: 1024 });
  analyser.connect(ctx.destination);

  function noteOn(smooth) {
    const t = ctx.currentTime;
    const osc = new OscillatorNode(ctx, { frequency: 330 });
    const gain = new GainNode(ctx, { gain: 0 });
    osc.connect(gain).connect(analyser);
    if (smooth) {
      gain.gain.setValueAtTime(0, t);
      gain.gain.linearRampToValueAtTime(0.4, t + 0.03);  // attack: 30 ms
    } else {
      gain.gain.value = 0.4;  // full volume at once
    }
    osc.start(t);
    return { osc, gain, smooth };
  }

  function noteOff(v) {
    const t = ctx.currentTime;
    if (!v.smooth) { v.osc.stop(); return; }  // cut the wave wherever it is
    const g = v.gain.gain;
    g.cancelScheduledValues(t);    // drop the attack ramp if it is still running
    g.setValueAtTime(g.value, t);  // hold the level we are at now
    g.setTargetAtTime(0, t, 0.08); // then glide down to silence
    v.osc.stop(t + 0.6);
  }

  for (const [id, smooth] of [['hard', false], ['soft', true]]) {
    const pad = document.getElementById(id);
    let voice = null;
    pad.addEventListener('pointerdown', (e) => {
      ctx.resume();  // pressing a pad counts as the user gesture
      pad.setPointerCapture(e.pointerId);
      pad.classList.add('down');
      voice = noteOn(smooth);
    });
    pad.addEventListener('lostpointercapture', () => {  // release or cancel
      pad.classList.remove('down');
      if (voice) noteOff(voice);
      voice = null;
    });
  }

  // Draw the output level over the last few seconds
  const cv = document.getElementById('scope'), g2 = cv.getContext('2d');
  const buf = new Float32Array(analyser.fftSize);
  const levels = new Array(240).fill(0);
  function draw() {
    analyser.getFloatTimeDomainData(buf);
    let peak = 0;
    for (const s of buf) peak = Math.max(peak, Math.abs(s));
    levels.push(peak); levels.shift();
    g2.clearRect(0, 0, cv.width, cv.height);
    g2.strokeStyle = '#2563eb'; g2.lineWidth = 4; g2.beginPath();
    levels.forEach((v, i) => {
      const x = i * cv.width / (levels.length - 1), y = cv.height - 20 - v * 500;
      i ? g2.lineTo(x, y) : g2.moveTo(x, y);
    });
    g2.stroke();
    requestAnimationFrame(draw);
  }
  draw();
</script>
</body>
</html>
Left: gain set at once and osc.stop() on release. Right: a 30 ms ramp in and a glide out.

All scheduling uses the context's clock, ctx.currentTime, in seconds. The attack is a short linear ramp from zero:

const t = ctx.currentTime;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.4, t + 0.03);  // 30 ms attack

The release needs care, because the key can come up while the attack is still running. Cancel what is scheduled, pin the current level, then glide down:

const g = gain.gain;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t);   // start the fade from where we are
g.setTargetAtTime(0, t, 0.08);  // glide towards 0
osc.stop(t + 0.6);              // stop after the fade

setTargetAtTime moves a set fraction of the remaining distance per time constant. After one time constant (here 0.08 s) the gain is 63% of the way to zero. After five, less than 1% is left, so stop the oscillator around then.

Method What it does Watch out
setValueAtTime Jumps to a value at a time A jump at audible volume clicks
linearRampToValueAtTime Straight line to a value The ramp starts at the previous event, so set one first
exponentialRampToValueAtTime Curved ramp The target cannot be 0 (RangeError)
setTargetAtTime Glides towards a value Never lands exactly; stop the source later
cancelScheduledValues Removes events from a time on Follow it with setValueAtTime

Build a small keyboard synth

The finished example turns this into an instrument. Play it with the mouse, a finger or the computer keys A to K. Hold two keys for a chord.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keyboard synth with a visualiser</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #111827; color: #e5e7eb; }
  .wrap { max-width: 520px; margin: 0 auto; }
  .bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; font-size: 14px; margin-bottom: 10px; }
  select, button { font-size: 14px; padding: 5px 8px; border-radius: 6px; border: 1px solid #374151; background: #1f2937; color: #e5e7eb; }
  #state { font: 12px ui-monospace, Consolas, monospace; color: #9ca3af; }
  canvas { display: block; width: 100%; height: 130px; background: #0b1020; border-radius: 10px; }
  .keys { position: relative; height: 170px; margin-top: 12px; touch-action: none; user-select: none; -webkit-user-select: none; }
  .key { position: absolute; top: 0; box-sizing: border-box; border-radius: 0 0 6px 6px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 6px; font: 12px ui-monospace, Consolas, monospace; }
  .white { height: 100%; background: #f9fafb; color: #6b7280; border: 1px solid #9ca3af; z-index: 1; }
  .black { height: 60%; background: #1f2937; color: #9ca3af; border: 1px solid #000; z-index: 2; }
  .key.on { background: #60a5fa; color: #fff; }
  p { font-size: 13px; color: #9ca3af; margin: 8px 0 0; }
</style>
</head>
<body>
<div class="wrap">
  <div class="bar">
    <label>Wave <select id="wave"><option>triangle</option><option>sine</option><option>square</option><option>sawtooth</option></select></label>
    <button id="view">Show: waveform</button>
    <span id="state"></span>
  </div>
  <canvas id="viz" width="1040" height="260"></canvas>
  <div class="keys" id="keys"></div>
  <p>Tap or hold the keys, or play with your keyboard: A W S E D F T G Y H U J K.</p>
</div>

<script>
  const ctx = new AudioContext();
  const master = new GainNode(ctx, { gain: 0.25 });  // overall volume, so chords do not distort
  const analyser = new AnalyserNode(ctx, { fftSize: 2048 });
  master.connect(analyser).connect(ctx.destination);

  // 13 notes from C4 (MIDI 60) to C5, and the computer key for each
  const LETTERS = 'awsedftgyhujk';
  const notes = [...LETTERS].map((k, i) => ({ midi: 60 + i, key: k, black: [1, 3, 6, 8, 10].includes(i) }));
  const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);  // A4 = MIDI 69 = 440 Hz

  // Draw the keyboard: 8 white keys, black keys on top
  const keysEl = document.getElementById('keys');
  const W = 100 / 8;  // white key width in %
  let white = 0;
  for (const n of notes) {
    n.el = document.createElement('div');
    n.el.className = 'key ' + (n.black ? 'black' : 'white');
    n.el.textContent = n.key.toUpperCase();
    n.el.style.left = (n.black ? white * W - W * 0.3 : white * W) + '%';
    n.el.style.width = (n.black ? W * 0.6 : W) + '%';
    if (!n.black) white++;
    keysEl.append(n.el);
  }

  const voices = new Map();  // midi -> { osc, gain }, one voice per held note

  function noteOn(n) {
    if (voices.has(n.midi)) return;
    if (ctx.state === 'suspended') ctx.resume();
    const t = ctx.currentTime;
    const osc = new OscillatorNode(ctx, { type: document.getElementById('wave').value, frequency: hz(n.midi) });
    const gain = new GainNode(ctx, { gain: 0 });
    osc.connect(gain).connect(master);
    gain.gain.setValueAtTime(0, t);
    gain.gain.linearRampToValueAtTime(1, t + 0.015);    // attack
    gain.gain.setTargetAtTime(0.6, t + 0.015, 0.1);     // decay to a sustain level
    osc.start(t);
    voices.set(n.midi, { osc, gain });
    n.el.classList.add('on');
  }

  function noteOff(n) {
    const v = voices.get(n.midi);
    if (!v) return;
    const t = ctx.currentTime, g = v.gain.gain;
    g.cancelScheduledValues(t);
    g.setValueAtTime(g.value, t);
    g.setTargetAtTime(0, t, 0.06);  // release
    v.osc.stop(t + 0.5);
    voices.delete(n.midi);
    n.el.classList.remove('on');
  }

  // Computer keyboard
  const byKey = Object.fromEntries(notes.map((n) => [n.key, n]));
  document.addEventListener('keydown', (e) => {
    const n = byKey[e.key.toLowerCase()];
    if (!n || e.repeat || e.ctrlKey || e.metaKey) return;  // ignore auto-repeat
    noteOn(n);
  });
  document.addEventListener('keyup', (e) => {
    const n = byKey[e.key.toLowerCase()];
    if (n) noteOff(n);
  });
  window.addEventListener('blur', () => notes.forEach(noteOff));  // no stuck notes

  // Mouse and touch: each pointer holds one note, and can slide to the next key
  const held = new Map();  // pointerId -> note
  function pointerNote(e) {
    const el = document.elementFromPoint(e.clientX, e.clientY);
    return notes.find((n) => n.el === el);
  }
  keysEl.addEventListener('pointerdown', (e) => {
    keysEl.setPointerCapture(e.pointerId);
    const n = pointerNote(e);
    if (n) { held.set(e.pointerId, n); noteOn(n); }
  });
  keysEl.addEventListener('pointermove', (e) => {
    if (!held.has(e.pointerId)) return;
    const n = pointerNote(e), old = held.get(e.pointerId);
    if (n && n !== old) { noteOff(old); held.set(e.pointerId, n); noteOn(n); }
  });
  keysEl.addEventListener('lostpointercapture', (e) => {
    const n = held.get(e.pointerId);
    if (n) noteOff(n);
    held.delete(e.pointerId);
  });

  // Visualiser: waveform or frequency bars from the AnalyserNode
  const cv = document.getElementById('viz'), g2 = cv.getContext('2d');
  const viewBtn = document.getElementById('view');
  let bars = false;
  viewBtn.addEventListener('click', () => {
    bars = !bars;
    viewBtn.textContent = 'Show: ' + (bars ? 'frequency bars' : 'waveform');
  });
  const wave = new Uint8Array(analyser.fftSize);          // 2048 samples
  const freq = new Uint8Array(analyser.frequencyBinCount); // 1024 bins

  function draw() {
    g2.clearRect(0, 0, cv.width, cv.height);
    if (bars) {
      analyser.getByteFrequencyData(freq);
      const shown = 128, bw = cv.width / shown;  // the low bins hold these notes
      g2.fillStyle = '#60a5fa';
      for (let i = 0; i < shown; i++) {
        const h = freq[i] / 255 * cv.height;
        g2.fillRect(i * bw, cv.height - h, bw - 2, h);
      }
    } else {
      analyser.getByteTimeDomainData(wave);  // 128 = silence
      g2.strokeStyle = '#34d399'; g2.lineWidth = 3; g2.beginPath();
      for (let i = 0; i < wave.length; i++) {
        const x = i / (wave.length - 1) * cv.width, y = wave[i] / 255 * cv.height;
        i ? g2.lineTo(x, y) : g2.moveTo(x, y);
      }
      g2.stroke();
    }
    document.getElementById('state').textContent = 'AudioContext: ' + ctx.state;
    requestAnimationFrame(draw);
  }
  draw();
</script>
</body>
</html>
13 notes from C4 to C5, one voice per held key, and an AnalyserNode drawing the output.

Three ideas make it work:

  1. One voice per note. A Map holds the oscillator and gain node of each sounding note. Key down adds a voice; key up releases it.
  2. Pitch from a note number. The note numbering used by MIDI puts A4 at 69 and 440 Hz. Each step is one semitone, a factor of the twelfth root of 2.
  3. A master gain. Every voice goes into one shared GainNode set to 0.25, so a chord does not overload the output.
const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);  // 60 = C4

Holding a key makes the keyboard repeat keydown, with e.repeat set to true after the first one. The synth ignores repeats, or each repeat would start another voice.

A blur listener releases every voice, because the keyup of a key released after the window lost focus does not reach the page. The addEventListener guide covers these events.

On screen, the keys use pointer events with touch-action: none, so a finger can slide from key to key without scrolling the page.

Draw the sound with an AnalyserNode

An AnalyserNode sits in the chain and changes nothing. On each frame, the script copies its latest data into an array and draws it on a canvas. The synth draws a waveform by default; the button switches to frequency bars.

const analyser = new AnalyserNode(ctx, { fftSize: 2048 });
master.connect(analyser).connect(ctx.destination);

const wave = new Uint8Array(analyser.fftSize);          // 2048 values
const bars = new Uint8Array(analyser.frequencyBinCount); // 1024 values
analyser.getByteTimeDomainData(wave);  // 128 means silence
analyser.getByteFrequencyData(bars);   // 0 to 255 per bin
  • Waveform: getByteTimeDomainData copies the latest fftSize samples. 128 is the centre line.
  • Frequency bars: getByteFrequencyData fills frequencyBinCount values, half of fftSize. Each bin is the sample rate divided by fftSize hertz wide.
  • Redraw: call it from requestAnimationFrame, which matches the screen's refresh. Drawing on a canvas covers the drawing calls.

The notes on this keyboard have their base pitch below bin 30, so the example draws only the first 128 bins, which leaves room for overtones. Drawing all 1024 would squeeze the notes into a thin strip at the left.

When it does not work

What you see Cause Fix
Silence, no error, currentTime stuck at 0 The context is suspended ctx.resume() inside a click or key handler
InvalidStateError on start() The oscillator was started before New OscillatorNode per note
A click at the start or end of a note Gain jumps, or stop() cuts the wave Ramp in, setTargetAtTime out, stop later
RangeError from a ramp exponentialRampToValueAtTime to 0 Use setTargetAtTime(0, ...), or ramp to 0.0001
Notes never stop The keyup was missed, or voices were not tracked Keep a Map of voices, release all on blur
Chords distort The voices add up past full scale Route voices through a master gain below 1
The visualiser is a flat line The analyser is not in the sound's path Connect the chain through the analyser

A synth is something people want to press, not read about. A recording loses the playing, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can play the keys themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is an AudioContext in JavaScript?

It is the object that owns a Web Audio graph. Every node, such as an OscillatorNode or a GainNode, is created for one context, and the context has its own clock (currentTime) and its own output (destination). One context per page is enough.

Why is my AudioContext suspended?

Browsers can create the context in the suspended state when the visitor has not interacted with the page yet. Call ctx.resume() inside a click, tap or key press handler. A suspended context throws no error; its clock simply does not move, so nothing is heard.

Why can I not start an OscillatorNode twice?

Source nodes are one-shot. A second start() on the same oscillator throws InvalidStateError. Create a new OscillatorNode and GainNode for every note; they are cheap. Keep the AudioContext and reuse it.

How do I stop the click at the start and end of a note?

Never start or stop at full volume. Ramp the GainNode up over a few milliseconds when the note starts, and on release glide it down with setTargetAtTime before calling stop() a little later.

How do I draw a waveform or spectrum of the sound?

Put an AnalyserNode in the chain. On every animation frame, call getByteTimeDomainData() for the waveform or getByteFrequencyData() for frequency bars, and draw the numbers on a canvas.

Keep reading