Skip to content
Journey to Code

Learn the Web Audio API by Making Sound

Spatial Audio, Music Systems, and Production Applications with Web Audio API

Pan a Sound Around Your Head, Keep Time, and Draw What You Hear

· By · Updated · 10 min read · 2 views

Cover reading "Spatial Audio, Music Systems," with an oscillator to panner graph and a top-down view of a source five units ahead of the listener

By the end of this post you will hear a tone move around your head, run a metronome whose beats land exactly on time, and draw a live picture of a sound. The main thread is the first one: placing a sound in space.

Sections marked Optional (MIDI instruments, the mixing console) are for readers who want to go further. Skipping them loses nothing.

Quick recap of the vocabulary from the earlier posts, in case you are starting here: an oscillator makes a tone at a frequency in hertz (Hz); a gain node is a volume knob (1 is full scale, 0.5 is half); a.connect(b) sends the sound from a into b, and the chain ends at audioContext.destination, your speakers or headphones. Browsers usually keep sound off until the reader has clicked or pressed a key, so every snippet here assumes the AudioContext was created inside a click handler (the runnable examples do this for you).

3D Spatial Audio: Placing a Sound in Space#

Understanding Spatial Audio Fundamentals#

Spatial audio creates the illusion that sounds exist in three-dimensional space around the listener. It works because the brain locates a sound from small differences in timing, level and tone between our two ears.

The Web Audio API provides tools for this through the PannerNode and the AudioListener. Two ideas to start with:

  • The listener is you. audioContext.listener stands for your head. By default it sits at the origin (0, 0, 0), faces straight ahead along the negative z axis, and has the top of its head along +y.
  • Positions are (x, y, z) numbers in arbitrary units. Picture looking into the screen: +x is to your right, +y is up, and +z points out of the screen toward you (so behind the listener), which is why "5 units in front" is z = -5. The API does not say whether a unit is a meter or a centimeter; you choose, and the distance settings below decide how fast sound fades.

Let's explore how to bring sounds to life in 3D space.

Implementing 3D Positioning with PannerNode#

The PannerNode is your gateway to positioning sounds in 3D space. Here's a simple example:

// Create an audio context (in a real page, do this inside a click handler)
const audioContext = new AudioContext();

// Create a sound source, with a gain node to keep it quiet
const oscillator = audioContext.createOscillator();
oscillator.frequency.value = 440;
const volume = audioContext.createGain();
volume.gain.value = 0.2;

// Create a panner node
const panner = audioContext.createPanner();
panner.panningModel = 'HRTF'; // Use Head-Related Transfer Function for realistic 3D
panner.distanceModel = 'inverse';
panner.refDistance = 1;
panner.maxDistance = 10000;
panner.rolloffFactor = 1;

// Set the initial position (x, y, z)
panner.positionX.value = 0;
panner.positionY.value = 0;
panner.positionZ.value = -5; // 5 units in front of the listener

// Connect the nodes: oscillator -> volume -> panner -> speakers
oscillator.connect(volume);
volume.connect(panner);
panner.connect(audioContext.destination);

// Start the oscillator
oscillator.start();

What you would hear: a steady, fairly quiet tone that seems to come from straight ahead of you, a little distance away. HRTF (explained below) is what makes headphones place it "outside" your head.

Challenge: left ear, then right ear#

Here is the whole thing as a page you can run. Wear headphones. It plays a two-second tone at x = 0, one unit in front of you, so you hear it in the middle.

Your task: change positionX to -5, run it, then change it to 5 and run it again.

What you should hear: at -5 the tone is in your left ear (and quieter, because it is further away: about 5 units instead of 1). At 5 it is in your right ear.

Challenge: left ear, then right ear
---html
<button id="play">Play two seconds</button>
---js
let ctx = null;

document.getElementById('play').onclick = () => {
  const Ctx = window.AudioContext || window.webkitAudioContext;
  if (!ctx) ctx = new Ctx();
  if (ctx.state === 'suspended') ctx.resume();

  const osc = ctx.createOscillator();
  osc.frequency.value = 440;
  const volume = ctx.createGain();
  volume.gain.value = 0.3;

  const panner = ctx.createPanner();
  panner.panningModel = 'HRTF';
  panner.positionX.value = 0;    // TODO: set -5, run; then set 5, run
  panner.positionY.value = 0;
  panner.positionZ.value = -1;   // one unit in front of you

  osc.connect(volume);
  volume.connect(panner);
  panner.connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 2);
};

A sound circling you#

Here is the same panner moving. A triangle tone travels around you in a circle 3 units wide, once every four seconds: front, then right, behind, left. Keep the headphones on. Notice how the "behind" part of the lap sounds duller than the front: that is the HRTF at work.

A sound circling your head
---html
<button id="start">Start</button> <button id="stop" disabled>Stop</button>
---js
let ctx = null, osc = null, frame = 0;

document.getElementById('start').onclick = () => {
  const Ctx = window.AudioContext || window.webkitAudioContext;
  if (!ctx) ctx = new Ctx();
  if (ctx.state === 'suspended') ctx.resume();

  osc = ctx.createOscillator();
  osc.type = 'triangle';
  osc.frequency.value = 330;
  const volume = ctx.createGain();
  volume.gain.value = 0.2;
  const panner = ctx.createPanner();
  panner.panningModel = 'HRTF';

  osc.connect(volume); volume.connect(panner); panner.connect(ctx.destination);
  osc.start();

  const begin = ctx.currentTime;
  function move() {
    const angle = ((ctx.currentTime - begin) / 4) * 2 * Math.PI; // one lap per 4 s
    panner.positionX.value = 3 * Math.sin(angle);   // right when positive
    panner.positionZ.value = -3 * Math.cos(angle);  // negative z is in front
    frame = requestAnimationFrame(move);
  }
  move();
  document.getElementById('start').disabled = true;
  document.getElementById('stop').disabled = false;
};

document.getElementById('stop').onclick = () => {
  cancelAnimationFrame(frame);
  osc.stop();
  document.getElementById('start').disabled = false;
  document.getElementById('stop').disabled = true;
};
With the default listener, which positionZ puts a sound in front of you?

Distance-Based Effects and Attenuation#

In real-world acoustics, sounds attenuate (reduce in volume) as they move farther from the listener. The Web Audio API models this through the PannerNode's distance models. The default, inverse, scales volume by refDistance / (refDistance + rolloffFactor * (distance - refDistance)), so at the default settings a sound 5 units away has a fifth of the volume it has at 1 unit, and at 10 units a tenth. Closer than refDistance the volume stops rising: the distance is treated as refDistance. In Chrome 156, a source at 0.5 units rendered at the same gain as one at 1 unit, and 20000 units out it was still getting quieter (maxDistance only limits the linear model):

// Choose a distance attenuation model
panner.distanceModel = 'inverse'; // Options: 'linear', 'inverse', 'exponential'
panner.refDistance = 1; // Distance at which the volume reduction begins
panner.maxDistance = 10000; // Only used by the 'linear' model; 'inverse' ignores it
panner.rolloffFactor = 1; // How quickly the volume reduces with distance

What you would hear: move the sound from 1 unit to 5 units away and it gets much quieter but does not change pitch or tone. Raise rolloffFactor to make it fade faster. The "3D Spatial Audio Example" below lets you drag the sound and watch (and hear) this.

With distanceModel = 'inverse', refDistance = 1 and rolloffFactor = 1, how loud is a sound 4 units away compared with 1 unit away?

Moving Sound Sources and a Hand-Made Doppler Effect#

To make a source move, change its position over time. Use the audio clock (audioContext.currentTime) for the timing rather than adding a fixed step per animation frame: a step per frame runs faster on a 120 Hz screen than on a 60 Hz one, so "0.1 per frame" is 6 units per second on one and 12 on the other.

The Doppler effect is the pitch change you hear when a siren passes: higher while it approaches, lower as it leaves. Older tutorials set panner.velocityX (and setVelocity()) to get it. The Doppler feature was removed from the Web Audio specification. Firefox dropped it in version 63 and WebKit removed setVelocity() too, so PannerNode has no velocity properties and nothing applies a Doppler shift for you. In Chrome 156, panner.velocityX is undefined, so assigning panner.velocityX.value throws an error (checked October 2026). You can still make the effect yourself: work out how fast the source is moving toward the listener and change the oscillator's frequency by speedOfSound / (speedOfSound - speedToward).

const speedOfSound = 343; // meters per second, if 1 unit is 1 meter
const baseFrequency = 440;
const speed = 30;         // the source moves right at 30 units per second
const startX = -30;
const z = -2;             // it passes 2 units in front of the listener
const startedAt = audioContext.currentTime;

function animateSound() {
  const elapsed = audioContext.currentTime - startedAt;
  const x = startX + speed * elapsed;
  panner.positionX.value = x;
  panner.positionZ.value = z;

  // How fast is the source moving toward the listener at (0, 0, 0)?
  const distance = Math.hypot(x, z);
  const speedToward = (speed * -x) / distance; // positive when approaching

  oscillator.frequency.value = baseFrequency * speedOfSound / (speedOfSound - speedToward);

  if (x < 30) requestAnimationFrame(animateSound);
}
animateSound();

What you would hear: a tone that starts about a semitone and a half sharp as it approaches (at 30 units per second, 343 / (343 - 30) is a ratio of 1.096, which is 1.58 semitones), slides through its true pitch of 440 Hz as it passes in front of you, and ends about a semitone and a half flat as it moves away, sweeping across from your left ear to your right: a passing siren.

Enhanced Spatial Realism with HRTF#

The Head-Related Transfer Function (HRTF) models how your head and ears change a sound depending on where it comes from. Enabling HRTF in the Web Audio API improves spatial realism, mostly on headphones:

// Enable HRTF for more realistic 3D audio
panner.panningModel = 'HRTF'; // Other option: 'equalpower' (less realistic)

Creating Directional Sound Sources#

Sound sources can be directional, projecting sound primarily in one direction:

// Configure the sound cone for directional audio
panner.coneInnerAngle = 40; // Full volume within this angle
panner.coneOuterAngle = 180; // Reduced volume outside inner angle, up to this angle
panner.coneOuterGain = 0.1; // Volume multiplier outside the outer angle
// The cone points along the panner's orientation (default: along +x),
// set with panner.orientationX/Y/Z.
3D Spatial Audio Example
---html
<div class="container">
	<h1>3D Spatial Audio Example</h1>
	<p>Click the "Start Audio" button, then drag the red sound source around the blue listener to experience 3D spatial audio. Wear headphones and keep your volume low.</p>

	<div class="controls">
		<button id="start-audio">Start Audio</button>

		<div>
			<label for="panning-model">Panning Model:</label>
			<select id="panning-model">
				<option value="HRTF">HRTF (more realistic)</option>
				<option value="equalpower">Equal Power</option>
			</select>
		</div>

		<div>
			<label for="distance-model">Distance Model:</label>
			<select id="distance-model">
				<option value="inverse">Inverse (1/distance)</option>
				<option value="linear">Linear</option>
				<option value="exponential">Exponential</option>
			</select>
		</div>

		<div>
			<label for="rolloff-factor">Rolloff Factor: <span id="rolloff-value">1</span></label>
			<input type="range" id="rolloff-factor" min="0" max="5" step="0.1" value="1">
		</div>
	</div>

	<div class="playground" id="playground">
		<div class="listener" id="listener"></div>
		<div class="sound-source" id="sound-source"></div>
	</div>
</div>
---css
:root {
	color-scheme: light dark;
}

body {
	font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
		sans-serif;
	max-width: 800px;
	margin: 0 auto;
	padding: 20px;
	line-height: 1.6;
}

.container {
	display: flex;
	flex-direction: column;
	align-items: center;
}

.controls {
	margin: 20px 0;
	display: flex;
	flex-direction: column;
	gap: 15px;
	width: 100%;
	max-width: 500px;
}

.playground {
	width: 100%;
	height: 300px;
	position: relative;
	border: 1px solid #666;
	border-radius: 8px;
	margin-top: 20px;
	overflow: hidden;
}

.listener {
	position: absolute;
	width: 30px;
	height: 30px;
	background-color: #3498db;
	border-radius: 50%;
	transform: translate(-50%, -50%);
	z-index: 2;
}

.sound-source {
	position: absolute;
	width: 20px;
	height: 20px;
	background-color: #e74c3c;
	border-radius: 50%;
	transform: translate(-50%, -50%);
	cursor: pointer;
	z-index: 1;
}

label {
	display: block;
	margin-bottom: 5px;
}

button {
	padding: 8px 15px;
	background-color: #3498db;
	color: white;
	border: none;
	border-radius: 4px;
	cursor: pointer;
	margin: 5px;
}

button:hover {
	background-color: #2980b9;
}

@media (prefers-color-scheme: dark) {
	body {
		background-color: #121212;
		color: #e0e0e0;
	}

	.playground {
		border-color: #444;
		background-color: #1e1e1e;
	}

	button {
		background-color: #2980b9;
	}

	button:hover {
		background-color: #3498db;
	}
}
---js
document.addEventListener("DOMContentLoaded", function () {
  let audioContext;
  let panner;
  let oscillator;
  let isPlaying = false;

  const playground = document.getElementById("playground");
  const listener = document.getElementById("listener");
  const soundSource = document.getElementById("sound-source");
  const startButton = document.getElementById("start-audio");
  const panningModelSelect = document.getElementById("panning-model");
  const distanceModelSelect = document.getElementById("distance-model");
  const rolloffFactorInput = document.getElementById("rolloff-factor");
  const rolloffValue = document.getElementById("rolloff-value");

  // Initialize positions
  const playgroundRect = playground.getBoundingClientRect();
  const listenerX = playgroundRect.width / 2;
  const listenerY = playgroundRect.height / 2;

  listener.style.left = `${listenerX}px`;
  listener.style.top = `${listenerY}px`;

  soundSource.style.left = `${listenerX + 100}px`;
  soundSource.style.top = `${listenerY}px`;

  // Initialize audio context and panner
  function initAudio() {
    audioContext = new (window.AudioContext || window.webkitAudioContext)();

    // Create oscillator
    oscillator = audioContext.createOscillator();
    oscillator.type = "sine";
    oscillator.frequency.value = 440;

    // Create panner node
    panner = audioContext.createPanner();
    updatePannerSettings();

    // Connect nodes (a gain node keeps the tone quiet)
    const volume = audioContext.createGain();
    volume.gain.value = 0.2;
    oscillator.connect(volume);
    volume.connect(panner);
    panner.connect(audioContext.destination);

    // Start oscillator
    oscillator.start();
    isPlaying = true;

    // Update positions
    updatePositions();
  }

  function updatePannerSettings() {
    if (!panner) return;

    panner.panningModel = panningModelSelect.value;
    panner.distanceModel = distanceModelSelect.value;
    panner.rolloffFactor = parseFloat(rolloffFactorInput.value);

    // Position listener at center of playground
    const listener = audioContext.listener;

    // Set listener position (always at center)
    if (listener.positionX) {
      // Modern API
      listener.positionX.value = 0;
      listener.positionY.value = 0;
      listener.positionZ.value = 0;
    } else {
      // Legacy API
      listener.setPosition(0, 0, 0);
    }

    updatePositions();
  }

  function updatePositions() {
    if (!panner || !audioContext) return;

    const playgroundRect = playground.getBoundingClientRect();
    const listenerX = playgroundRect.width / 2;
    const listenerY = playgroundRect.height / 2;

    const soundSourceRect = soundSource.getBoundingClientRect();
    const soundX =
    soundSourceRect.left + soundSourceRect.width / 2 - playgroundRect.left;
    const soundY =
    soundSourceRect.top + soundSourceRect.height / 2 - playgroundRect.top;

    // Calculate position relative to listener
    const posX = (soundX - listenerX) / 50; // Scale down for better sound positioning
    const posY = 0; // Keep on same Y plane for simplicity
    const posZ = (soundY - listenerY) / 50; // Map Y position to Z in audio space

    // Set panner position
    if (panner.positionX) {
      // Modern API
      panner.positionX.value = posX;
      panner.positionY.value = posY;
      panner.positionZ.value = posZ;
    } else {
      // Legacy API
      panner.setPosition(posX, posY, posZ);
    }
  }

  // Make the sound source draggable
  let isDragging = false;

  soundSource.addEventListener("mousedown", function (e) {
    isDragging = true;
    e.preventDefault();
  });

  document.addEventListener("mousemove", function (e) {
    if (!isDragging) return;

    const playgroundRect = playground.getBoundingClientRect();

    // Constrain to playground boundaries
    let newX = Math.max(
    0,
    Math.min(playgroundRect.width, e.clientX - playgroundRect.left));

    let newY = Math.max(
    0,
    Math.min(playgroundRect.height, e.clientY - playgroundRect.top));


    soundSource.style.left = `${newX}px`;
    soundSource.style.top = `${newY}px`;

    updatePositions();
  });

  document.addEventListener("mouseup", function () {
    isDragging = false;
  });

  // Event listeners
  startButton.addEventListener("click", function () {
    if (!isPlaying) {
      initAudio();
      this.textContent = "Audio Started";
      this.disabled = true;
    }
  });

  panningModelSelect.addEventListener("change", updatePannerSettings);
  distanceModelSelect.addEventListener("change", updatePannerSettings);

  rolloffFactorInput.addEventListener("input", function () {
    rolloffValue.textContent = this.value;
    updatePannerSettings();
  });

  // Handle window resize
  window.addEventListener("resize", function () {
    // Reposition elements after resize
    const playgroundRect = playground.getBoundingClientRect();
    const listenerX = playgroundRect.width / 2;
    const listenerY = playgroundRect.height / 2;

    listener.style.left = `${listenerX}px`;
    listener.style.top = `${listenerY}px`;

    updatePositions();
  });
});

Building a Complete Music System#

Tempo and Beat Management#

Music timing starts with one conversion. Tempo is measured in beats per minute (BPM), so the time between beats is 60 / bpm seconds: 1 second at 60 BPM, 0.5 s at 120, one third of a second at 180. A TempoManager keeps that number and lets you ask for "this callback, N beats from now". It records the event and the audio-clock time it is due, and a fireDue() method (which the metronome below calls on every scheduler pass) runs the callbacks whose time has come:

class TempoManager {
  constructor(audioContext, bpm = 120) {
    this.audioContext = audioContext;
    this.bpm = bpm;
    this.quarterNoteTime = 60 / this.bpm; // seconds per beat
    this.events = [];                     // events waiting for their time
  }

  // Run `callback` when `beatPosition` beats have passed from now.
  // Returns the audio-clock time, which you can also pass to source.start(time).
  scheduleAt(callback, beatPosition) {
    const timeInSeconds = beatPosition * this.quarterNoteTime;
    const deadline = this.audioContext.currentTime + timeInSeconds;
    this.events.push({ callback, deadline });
    return deadline;
  }

  // Call this often (the metronome's scheduler does). It fires everything that is due.
  // It is for visuals and bookkeeping: for audio, use the returned deadline with start(time).
  fireDue() {
    const now = this.audioContext.currentTime;
    this.events = this.events.filter((event) => {
      if (event.deadline <= now) {
        event.callback(event.deadline);
        return false; // fired, so remove it
      }
      return true;
    });
  }

  setBpm(newBpm) {
    this.bpm = newBpm;
    this.quarterNoteTime = 60 / this.bpm;
  }
}

Building a Precise Metronome#

A metronome shows how to keep timing steady in Web Audio. First, hear and read the idea at its smallest. This example schedules six clicks ahead of time on the audio clock and prints the exact time each one is due. Press Run, then press one button, then the other. The first click, and every fourth click after it, is higher-pitched.

Metronome worked example: 60 BPM vs 180 BPM
---html
<button id="slow">60 BPM</button>
<button id="fast">180 BPM</button>
---js
let ctx = null;

function clicks(bpm) {
  const Ctx = window.AudioContext || window.webkitAudioContext;
  if (!ctx) ctx = new Ctx();
  if (ctx.state === 'suspended') ctx.resume();

  const secondsPerBeat = 60 / bpm;
  const first = ctx.currentTime + 0.1;
  console.log(bpm + ' BPM: ' + secondsPerBeat.toFixed(3) + ' s between clicks');

  for (let beat = 0; beat < 6; beat++) {
    const when = first + beat * secondsPerBeat;   // exact time on the audio clock
    const osc = ctx.createOscillator();
    osc.frequency.value = beat % 4 === 0 ? 1000 : 700;  // accent every fourth click
    const volume = ctx.createGain();
    volume.gain.value = 0.2;
    osc.connect(volume);
    volume.connect(ctx.destination);
    osc.start(when);
    osc.stop(when + 0.05);
    console.log('click ' + (beat + 1) + ' due at ' + when.toFixed(3) + ' s');
  }
}
document.getElementById('slow').onclick = () => clicks(60);
document.getElementById('fast').onclick = () => clicks(180);

What you should hear and see: at 60 BPM, six slow ticks, a second apart, over about five seconds. At 180 BPM, six quick ticks, a third of a second apart, over under two seconds. In the Console panel the due times in the second run should differ by 0.333 s, and in the first by 1.000 s. (The exact numbers depend on how long the page has been open, but the gaps do not.)

Now the same idea as a reusable class, with the lookahead scheduler from the foundations post (lookahead is how often the timer wakes, in milliseconds; scheduleAheadTime is how far ahead each wake-up schedules, in seconds, which is deliberately a longer window so a late timer does not drop a beat):

class Metronome {
  constructor(audioContext, tempo = 120) {
    this.audioContext = audioContext;
    this.isPlaying = false;
    this.tempo = tempo;
    this.lookahead = 25.0; // How often the timer wakes up (ms)
    this.scheduleAheadTime = 0.1; // How far ahead each wake-up schedules (seconds)
    this.nextNoteTime = 0; // When the next note is due
    this.currentBeat = 0;
    this.tempoManager = new TempoManager(audioContext, tempo);
    this.clickBuffer = this.createClickBuffer();
  }

  createClickBuffer() {
    // Create a short percussive click sound
    const buffer = this.audioContext.createBuffer(
      1, 
      this.audioContext.sampleRate * 0.1, 
      this.audioContext.sampleRate
    );
    const channelData = buffer.getChannelData(0);

    // Attack
    for (let i = 0; i < buffer.length * 0.1; i++) {
      channelData[i] = Math.sin(i * 0.1) * (1 - i / (buffer.length * 0.1));
    }

    return buffer;
  }

  nextNote() {
    // Advance current note and time by a quarter note
    this.nextNoteTime += 60.0 / this.tempo;

    // Advance the beat number
    this.currentBeat = (this.currentBeat + 1) % 4;
  }

  scheduleNote(beatNumber, time) {
    // Create click sound
    const clickSource = this.audioContext.createBufferSource();
    clickSource.buffer = this.clickBuffer;

    // Emphasized click on the first beat
    const clickVolume = this.audioContext.createGain();
    clickVolume.gain.value = beatNumber % 4 === 0 ? 0.4 : 0.2; // keep it gentle

    // Connect and play
    clickSource.connect(clickVolume);
    clickVolume.connect(this.audioContext.destination);
    clickSource.start(time);
  }

  scheduler() {
    // Schedule notes until the lookahead period
    while (this.nextNoteTime < this.audioContext.currentTime + this.scheduleAheadTime) {
      this.scheduleNote(this.currentBeat, this.nextNoteTime);
      this.nextNote();
    }

    // Run any callbacks registered with scheduleAt() that have come due
    this.tempoManager.fireDue();

    // Call scheduler again
    this.timerId = setTimeout(() => this.scheduler(), this.lookahead);
  }

  start() {
    if (this.isPlaying) return;

    this.isPlaying = true;
    this.currentBeat = 0;
    this.nextNoteTime = this.audioContext.currentTime;
    this.scheduler();
  }

  stop() {
    this.isPlaying = false;
    clearTimeout(this.timerId);
  }

  setTempo(bpm) {
    this.tempo = bpm;
    this.tempoManager.setBpm(bpm);
  }
}
Web Audio Metronome
---html
<div class="container">
	<h1>Web Audio Metronome</h1>
	<p>A precise metronome using Web Audio API's high-precision timing</p>

	<div class="tempo-display" id="tempo-display">120 BPM</div>

	<div class="controls">
		<div>
			<label for="tempo-slider">Tempo:</label>
			<input type="range" id="tempo-slider" min="40" max="240" step="1" value="120">
		</div>

		<div>
			<label for="time-signature">Time Signature:</label>
			<select id="time-signature">
				<option value="2">2/4</option>
				<option value="3">3/4</option>
				<option value="4" selected>4/4</option>
				<option value="5">5/4</option>
				<option value="6">6/8</option>
				<option value="7">7/8</option>
			</select>
		</div>

		<button id="start-stop">Start</button>
	</div>

	<div class="metronome-visual" id="metronome-visual">
		<!-- Beats will be added here dynamically -->
	</div>
</div>
---css
:root {
	color-scheme: light dark;
}

body {
	font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
		sans-serif;
	max-width: 800px;
	margin: 0 auto;
	padding: 20px;
	line-height: 1.6;
}

.container {
	display: flex;
	flex-direction: column;
	align-items: center;
}

.controls {
	margin: 20px 0;
	display: flex;
	flex-direction: column;
	gap: 15px;
	width: 100%;
	max-width: 400px;
}

.metronome-visual {
	display: flex;
	justify-content: space-between;
	width: 100%;
	max-width: 400px;
	margin: 20px 0;
}

.beat {
	width: 40px;
	height: 40px;
	border-radius: 50%;
	background-color: #555;
	transition: background-color 0.05s ease;
}

.beat.active {
	background-color: #e74c3c;
}

.beat.accent {
	border: 2px solid #e74c3c;
}

button {
	padding: 8px 15px;
	background-color: #3498db;
	color: white;
	border: none;
	border-radius: 4px;
	cursor: pointer;
	margin: 5px 0;
}

button:hover {
	background-color: #2980b9;
}

.tempo-display {
	font-size: 2em;
	font-weight: bold;
	margin: 10px 0;
}

label {
	display: block;
	margin-bottom: 5px;
}

select,
input {
	width: 100%;
	padding: 8px;
	border-radius: 4px;
	border: 1px solid #ddd;
	background-color: #f8f8f8;
}

@media (prefers-color-scheme: dark) {
	body {
		background-color: #121212;
		color: #e0e0e0;
	}

	.beat {
		background-color: #333;
	}

	select,
	input {
		background-color: #2a2a2a;
		border-color: #444;
		color: #e0e0e0;
	}

	button {
		background-color: #2980b9;
	}

	button:hover {
		background-color: #3498db;
	}
}
---js
document.addEventListener("DOMContentLoaded", function () {
  let audioContext;
  let metronome;
  let isPlaying = false;

  const startStopButton = document.getElementById("start-stop");
  const tempoSlider = document.getElementById("tempo-slider");
  const tempoDisplay = document.getElementById("tempo-display");
  const timeSignature = document.getElementById("time-signature");
  const metronomeVisual = document.getElementById("metronome-visual");

  // Metronome class for precise timing
  class Metronome {
    constructor(audioContext, tempo = 120, beatsPerMeasure = 4) {
      this.audioContext = audioContext;
      this.tempo = tempo;
      this.beatsPerMeasure = beatsPerMeasure;
      this.isPlaying = false;
      this.currentBeat = 0;

      // Scheduling parameters
      this.lookahead = 25; // How often to call scheduling function (in milliseconds)
      this.scheduleAheadTime = 0.1; // How far ahead to schedule audio (in seconds)
      this.nextNoteTime = 0; // When the next note is due
      this.timerID = null; // setTimeout identifier

      // Create click sounds
      this.clickHighBuffer = this.createClickBuffer(1000, 0.05); // Accent click
      this.clickLowBuffer = this.createClickBuffer(700, 0.05); // Regular click

      // Callback for beat change
      this.onBeatChange = null;
    }

    createClickBuffer(frequency, duration) {
      const sampleRate = this.audioContext.sampleRate;
      const buffer = this.audioContext.createBuffer(
      1,
      sampleRate * duration,
      sampleRate);

      const channelData = buffer.getChannelData(0);

      // Create a simple percussive envelope
      for (let i = 0; i < buffer.length; i++) {
        const t = i / sampleRate;
        // Sine wave with exponential decay
        channelData[i] = Math.sin(2 * Math.PI * frequency * t) * Math.exp(-8 * t);
      }

      return buffer;
    }

    nextNote() {
      // Advance current note and time by a quarter note
      const secondsPerBeat = 60.0 / this.tempo;
      this.nextNoteTime += secondsPerBeat;

      // Advance the beat number
      this.currentBeat = (this.currentBeat + 1) % this.beatsPerMeasure;
    }

    scheduleNote(beatNumber, time) {
      // Create source for click sound
      const source = this.audioContext.createBufferSource();

      // Use accent click for first beat, regular click for others
      source.buffer =
      beatNumber === 0 ? this.clickHighBuffer : this.clickLowBuffer;

      // Connect to output
      source.connect(this.audioContext.destination);

      // Play sound at exact time
      source.start(time);

      // Call beat change callback if set
      if (this.onBeatChange) {
        // Use setTimeout to synchronize with sound as closely as possible
        const currentTime = this.audioContext.currentTime;
        const timeUntilNote = (time - currentTime) * 1000;
        setTimeout(() => this.onBeatChange(beatNumber), timeUntilNote);
      }
    }

    scheduler() {
      // Schedule notes until the look-ahead period
      while (
      this.nextNoteTime <
      this.audioContext.currentTime + this.scheduleAheadTime)
      {
        this.scheduleNote(this.currentBeat, this.nextNoteTime);
        this.nextNote();
      }

      // Call scheduler again after lookahead time
      this.timerID = setTimeout(() => this.scheduler(), this.lookahead);
    }

    start() {
      if (this.isPlaying) return;

      // Start from current time
      this.isPlaying = true;
      this.currentBeat = this.beatsPerMeasure - 1; // Start one before so first beat is 0
      this.nextNoteTime = this.audioContext.currentTime;

      // Start scheduling
      this.scheduler();
    }

    stop() {
      this.isPlaying = false;
      clearTimeout(this.timerID);
    }

    setTempo(newTempo) {
      this.tempo = newTempo;
    }

    setBeatsPerMeasure(beats) {
      this.beatsPerMeasure = beats;
      // Reset current beat if it's now out of range
      if (this.currentBeat >= beats) {
        this.currentBeat = 0;
      }
    }}


  // Setup metronome visuals based on time signature
  function setupMetronomeVisual(beatsPerMeasure) {
    metronomeVisual.innerHTML = "";

    for (let i = 0; i < beatsPerMeasure; i++) {
      const beat = document.createElement("div");
      beat.className = "beat";

      // Mark first beat as accent
      if (i === 0) {
        beat.classList.add("accent");
      }

      metronomeVisual.appendChild(beat);
    }
  }

  // Initialize audio and metronome
  function init() {
    // Create audio context
    audioContext = new (window.AudioContext || window.webkitAudioContext)();

    // Create metronome
    const beatsPerMeasure = parseInt(timeSignature.value);
    metronome = new Metronome(
    audioContext,
    parseInt(tempoSlider.value),
    beatsPerMeasure);


    // Setup visual beats
    setupMetronomeVisual(beatsPerMeasure);

    // Set beat change callback
    metronome.onBeatChange = function (beatNumber) {
      // Remove active class from all beats
      const beats = metronomeVisual.querySelectorAll(".beat");
      beats.forEach(beat => beat.classList.remove("active"));

      // Add active class to current beat
      beats[beatNumber].classList.add("active");
    };
  }

  // Event listeners
  startStopButton.addEventListener("click", function () {
    // Initialize on first click
    if (!audioContext) {
      init();
    }

    if (isPlaying) {
      metronome.stop();
      this.textContent = "Start";
      isPlaying = false;

      // Remove active class from all beats
      const beats = metronomeVisual.querySelectorAll(".beat");
      beats.forEach(beat => beat.classList.remove("active"));
    } else {
      metronome.start();
      this.textContent = "Stop";
      isPlaying = true;
    }
  });

  tempoSlider.addEventListener("input", function () {
    const tempo = parseInt(this.value);
    tempoDisplay.textContent = `${tempo} BPM`;

    if (metronome) {
      metronome.setTempo(tempo);
    }
  });

  timeSignature.addEventListener("change", function () {
    const beatsPerMeasure = parseInt(this.value);

    // Update visual representation
    setupMetronomeVisual(beatsPerMeasure);

    // Update metronome if initialized
    if (metronome) {
      metronome.setBeatsPerMeasure(beatsPerMeasure);
    }
  });

  // Initial setup
  setupMetronomeVisual(parseInt(timeSignature.value));
});

Musical Theory Integration#

A semitone is the smallest step on a piano, from one key to the very next one, black keys included; there are 12 in an octave. A scale is a pattern of semitone steps counted from a starting note (the root). NOTES below lists the 12 notes in order, and a scale array such as [0, 2, 4, 5, 7, 9, 11] (the major scale) means "the notes 0, 2, 4, 5, 7, 9 and 11 semitones above the root". From C that gives C D E F G A B, the white keys; from A, the minor pattern [0, 2, 3, 5, 7, 8, 10] gives A B C D E F G. A chord stacks scale notes, usually every other one (the first, third and fifth).

Using the major pattern [0, 2, 4, 5, 7, 9, 11] with root C, which notes does getScale return?

Let's implement a simple chord and scale generator:

class MusicTheory {
  // Chromatic scale starting from C
  static NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];

  // Scale patterns (semitone steps)
  static SCALES = {
    major: [0, 2, 4, 5, 7, 9, 11],
    minor: [0, 2, 3, 5, 7, 8, 10],
    pentatonicMajor: [0, 2, 4, 7, 9],
    pentatonicMinor: [0, 3, 5, 7, 10],
    blues: [0, 3, 5, 6, 7, 10]
  };

  // Chord patterns (scale degree positions)
  static CHORD_PATTERNS = {
    major: [0, 2, 4], // 1, 3, 5
    minor: [0, 2, 4], // 1, ♭3, 5
    diminished: [0, 2, 4], // not used: getChord builds diminished from semitones below
    augmented: [0, 2, 4], // 1, 3, #5
    sus2: [0, 1, 4], // 1, 2, 5
    sus4: [0, 3, 4], // 1, 4, 5
    major7: [0, 2, 4, 6], // 1, 3, 5, 7
    dominant7: [0, 2, 4, 6] // not used: getChord builds dominant7 from semitones below
  };

  static getScale(root, type) {
    const rootIndex = this.NOTES.indexOf(root);
    if (rootIndex === -1) throw new Error('Invalid root note');

    const pattern = this.SCALES[type];
    if (!pattern) throw new Error('Invalid scale type');

    return pattern.map(step => {
      const noteIndex = (rootIndex + step) % 12;
      return this.NOTES[noteIndex];
    });
  }

  static getChord(root, type) {
    // First get the appropriate scale for this chord type
    let scaleType;
    switch(type) {
      case 'major':
      case 'major7':
      case 'sus2':
      case 'sus4':
        scaleType = 'major';
        break;
      case 'minor':
        scaleType = 'minor';
        break;
      case 'diminished': // 1, flat 3, flat 5: 0, 3 and 6 semitones above the root
      case 'dominant7': { // 1, 3, 5, flat 7: 0, 4, 7 and 10 semitones above the root
        const steps = type === 'diminished' ? [0, 3, 6] : [0, 4, 7, 10];
        const rootIndex = this.NOTES.indexOf(root);
        return steps.map(step => this.NOTES[(rootIndex + step) % 12]);
      }
      case 'augmented':
        // Custom handling for augmented
        const majorScale = this.getScale(root, 'major');
        const notes = [majorScale[0], majorScale[2]];
        // Raise the 5th by a semitone
        const fifthIndex = (this.NOTES.indexOf(majorScale[4]) + 1) % 12;
        notes.push(this.NOTES[fifthIndex]);
        return notes;
      default:
        throw new Error('Invalid chord type');
    }

    const scale = this.getScale(root, scaleType);
    const pattern = this.CHORD_PATTERNS[type];

    // Map chord pattern to notes from the scale
    return pattern.map(degree => scale[degree]);
  }

  static noteToFrequency(note, octave = 4) {
    // A4 = 440Hz
    const A4 = 440;
    const A4_INDEX = this.NOTES.indexOf('A') + (4 * 12);

    const noteIndex = this.NOTES.indexOf(note) + (octave * 12);
    const semitoneDistance = noteIndex - A4_INDEX;

    // Each semitone is a factor of 2^(1/12)
    return A4 * Math.pow(2, semitoneDistance / 12);
  }
}

Building a Sequencer#

Now let's create a step sequencer for pattern-based music creation:

class StepSequencer {
  constructor(audioContext, steps = 16, tracks = 4) {
    this.audioContext = audioContext;
    this.steps = steps;
    this.tracks = tracks;
    this.currentStep = 0;
    this.isPlaying = false;
    this.tempo = 120;
    this.stepTime = 60 / this.tempo / 4; // Sixteenth notes
    this.nextStepTime = 0;
    this.patterns = Array(tracks).fill().map(() => Array(steps).fill(false));
    this.soundBuffers = [];
    this.scheduleAheadTime = 0.1;
    this.lookahead = 25;
  }

  loadSample(url, trackIndex) {
    return fetch(url)
      .then(response => response.arrayBuffer())
      .then(arrayBuffer => this.audioContext.decodeAudioData(arrayBuffer))
      .then(audioBuffer => {
        this.soundBuffers[trackIndex] = audioBuffer;
      });
  }

  toggleStep(trackIndex, stepIndex) {
    this.patterns[trackIndex][stepIndex] = !this.patterns[trackIndex][stepIndex];
  }

  nextStep() {
    this.nextStepTime += this.stepTime;
    this.currentStep = (this.currentStep + 1) % this.steps;
  }

  playSample(trackIndex, time) {
    if (!this.soundBuffers[trackIndex]) return;

    const source = this.audioContext.createBufferSource();
    source.buffer = this.soundBuffers[trackIndex];
    source.connect(this.audioContext.destination);
    source.start(time);
  }

  scheduler() {
    while (this.nextStepTime < this.audioContext.currentTime + this.scheduleAheadTime) {
      // Check each track for this step
      for (let track = 0; track < this.tracks; track++) {
        if (this.patterns[track][this.currentStep]) {
          this.playSample(track, this.nextStepTime);
        }
      }
      this.nextStep();
    }

    if (this.isPlaying) {
      setTimeout(() => this.scheduler(), this.lookahead);
    }
  }

  start() {
    if (this.isPlaying) return;

    this.isPlaying = true;
    this.currentStep = 0;
    this.nextStepTime = this.audioContext.currentTime;
    this.scheduler();
  }

  stop() {
    this.isPlaying = false;
  }

  setTempo(bpm) {
    this.tempo = bpm;
    this.stepTime = 60 / this.tempo / 4;
  }
}

Audio Analysis and Visualization#

Building a Frequency Analyzer#

An AnalyserNode listens to whatever passes through it and tells you what the sound looks like right now, which is how visualizers work. It passes the sound on unchanged. Three settings to know:

  • fftSize is how many recent samples the analyser looks at each time (a power of two, 2048 by default). The "FFT" (fast Fourier transform) is the math that turns those samples into a list of how much of each frequency is present.
  • frequencyBinCount is the number of frequency "bars" you get back: always fftSize / 2. With fftSize = 2048 it is 1024. Each bar covers sampleRate / fftSize Hz; at 48000 Hz and 2048 that is about 23 Hz. A bigger fftSize gives finer detail but reacts more slowly.
  • smoothingTimeConstant (0 to 1) says how much each reading is blended with the previous one. Near 0 the bars jump around; near 1 they move slowly and smoothly. The default is 0.8.
An analyser has fftSize = 2048. What is its frequencyBinCount?
class AudioAnalyzer {
  constructor(audioContext, fftSize = 2048) {
    this.audioContext = audioContext;
    this.analyser = audioContext.createAnalyser();
    this.analyser.fftSize = fftSize;
    this.analyser.smoothingTimeConstant = 0.85;

    this.frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
    this.timeData = new Uint8Array(this.analyser.fftSize);
  }

  connectSource(source) {
    source.connect(this.analyser);
    return this; // For chaining
  }

  getFrequencyData() {
    this.analyser.getByteFrequencyData(this.frequencyData);
    return this.frequencyData;
  }

  getTimeData() {
    this.analyser.getByteTimeDomainData(this.timeData);
    return this.timeData;
  }

  // Utility to get dominant frequency
  getDominantFrequency() {
    this.analyser.getByteFrequencyData(this.frequencyData);

    let maxIndex = 0;
    let maxValue = 0;

    for (let i = 0; i < this.frequencyData.length; i++) {
      if (this.frequencyData[i] > maxValue) {
        maxValue = this.frequencyData[i];
        maxIndex = i;
      }
    }

    // Convert bin index to frequency
    return maxIndex * this.audioContext.sampleRate / this.analyser.fftSize;
  }
}

Creating a Spectrum Visualizer#

Now let's build a visualizer that uses Canvas to display frequency data:

class SpectrumVisualizer {
  constructor(audioAnalyzer, canvasElement) {
    this.analyzer = audioAnalyzer;
    this.canvas = canvasElement;
    this.canvasCtx = this.canvas.getContext('2d');
    this.isAnimating = false;

    // Set canvas size to match display size
    this.resizeCanvas();
    window.addEventListener('resize', () => this.resizeCanvas());
  }

  resizeCanvas() {
    this.canvas.width = this.canvas.clientWidth;
    this.canvas.height = this.canvas.clientHeight;
  }

  start() {
    if (this.isAnimating) return;
    this.isAnimating = true;
    this.draw();
  }

  stop() {
    this.isAnimating = false;
  }

  draw() {
    if (!this.isAnimating) return;

    // Get frequency data
    const frequencyData = this.analyzer.getFrequencyData();

    // Clear canvas with semi-transparent black for trail effect
    this.canvasCtx.fillStyle = 'rgba(0, 0, 0, 0.2)';
    this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    // Draw spectrum
    const barWidth = this.canvas.width / frequencyData.length;
    let x = 0;

    for (let i = 0; i < frequencyData.length; i++) {
      const barHeight = frequencyData[i] / 255 * this.canvas.height;

      // Create gradient
      const hue = i / frequencyData.length * 360;
      this.canvasCtx.fillStyle = `hsl(${hue}, 100%, 50%)`;

      this.canvasCtx.fillRect(x, this.canvas.height - barHeight, barWidth, barHeight);
      x += barWidth;
    }

    requestAnimationFrame(() => this.draw());
  }
}

Creating a Waveform Display#

Similarly, we can visualize the time-domain data:

class WaveformVisualizer {
  constructor(audioAnalyzer, canvasElement) {
    this.analyzer = audioAnalyzer;
    this.canvas = canvasElement;
    this.canvasCtx = this.canvas.getContext('2d');
    this.isAnimating = false;

    this.resizeCanvas();
    window.addEventListener('resize', () => this.resizeCanvas());
  }

  resizeCanvas() {
    this.canvas.width = this.canvas.clientWidth;
    this.canvas.height = this.canvas.clientHeight;
  }

  start() {
    if (this.isAnimating) return;
    this.isAnimating = true;
    this.draw();
  }

  stop() {
    this.isAnimating = false;
  }

  draw() {
    if (!this.isAnimating) return;

    // Get time data
    const timeData = this.analyzer.getTimeData();

    // Clear canvas
    this.canvasCtx.fillStyle = 'rgba(0, 0, 0, 0.2)';
    this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    // Draw waveform
    this.canvasCtx.lineWidth = 2;
    this.canvasCtx.strokeStyle = '#00FFFF';
    this.canvasCtx.beginPath();

    const sliceWidth = this.canvas.width / timeData.length;
    let x = 0;

    for (let i = 0; i < timeData.length; i++) {
      const v = timeData[i] / 128.0; // Convert to range -1 to 1
      const y = v * this.canvas.height / 2;

      if (i === 0) {
        this.canvasCtx.moveTo(x, y);
      } else {
        this.canvasCtx.lineTo(x, y);
      }

      x += sliceWidth;
    }

    this.canvasCtx.lineTo(this.canvas.width, this.canvas.height / 2);
    this.canvasCtx.stroke();

    requestAnimationFrame(() => this.draw());
  }
}
Audio Analyzer & Visualizer
---html
<div class="container">
	<h1>Audio Analyzer & Visualizer</h1>
	<p>Real-time audio analysis using Web Audio API's AnalyserNode. Wear headphones and keep your volume low. The microphone is analysed but never played back, so it cannot squeal.</p>

	<div class="controls">
		<div class="control-group">
			<button id="mic-button">Use Microphone</button>
			<p>- or -</p>
			<button id="oscillator-button">Use Oscillator</button>

			<div id="oscillator-controls" style="display: none; margin-top: 10px;">
				<label for="waveform">Waveform:</label>
				<select id="waveform">
					<option value="sine">Sine</option>
					<option value="square">Square</option>
					<option value="sawtooth">Sawtooth</option>
					<option value="triangle">Triangle</option>
				</select>

				<label for="frequency">Frequency: <span id="frequency-value">440</span> Hz</label>
				<input type="range" id="frequency" min="20" max="2000" step="1" value="440">
			</div>
		</div>

		<div class="control-group">
			<label for="fft-size">FFT Size:</label>
			<select id="fft-size">
				<option value="32">32</option>
				<option value="64">64</option>
				<option value="128">128</option>
				<option value="256">256</option>
				<option value="512">512</option>
				<option value="1024">1024</option>
				<option value="2048" selected>2048</option>
				<option value="4096">4096</option>
				<option value="8192">8192</option>
				<option value="16384">16384</option>
			</select>

			<label for="smoothing">Smoothing:</label>
			<input type="range" id="smoothing" min="0" max="0.95" step="0.05" value="0.8">
			<span id="smoothing-value">0.8</span>
		</div>
	</div>

	<div class="visualizers">
		<div class="visualizer-container">
			<h2>Frequency Spectrum</h2>
			<canvas id="frequency-canvas"></canvas>
		</div>

		<div class="visualizer-container">
			<h2>Waveform</h2>
			<canvas id="waveform-canvas"></canvas>
		</div>
	</div>

	<div class="info">
		<h3>Audio Information</h3>
		<p id="audio-info">Connect an audio source to see analysis data.</p>
	</div>
</div>
---css
:root {
	color-scheme: light dark;
}

body {
	font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
		sans-serif;
	max-width: 900px;
	margin: 0 auto;
	padding: 20px;
	line-height: 1.6;
}

.container {
	display: flex;
	flex-direction: column;
	align-items: center;
}

.visualizers {
	display: flex;
	flex-direction: column;
	width: 100%;
	gap: 20px;
	margin-top: 20px;
}

.visualizer-container {
	width: 100%;
	border-radius: 8px;
	overflow: hidden;
}

canvas {
	display: block;
	width: 100%;
	height: 150px;
	background-color: rgba(0, 0, 0, 0.1);
}

.controls {
	margin: 20px 0;
	display: flex;
	flex-wrap: wrap;
	gap: 15px;
	width: 100%;
}

.control-group {
	flex: 1;
	min-width: 200px;
}

button {
	padding: 8px 15px;
	background-color: #3498db;
	color: white;
	border: none;
	border-radius: 4px;
	cursor: pointer;
	margin: 5px 0;
}

button:hover {
	background-color: #2980b9;
}

label {
	display: block;
	margin-bottom: 5px;
}

select,
input {
	width: 100%;
	padding: 8px;
	border-radius: 4px;
	border: 1px solid #ddd;
}

.info {
	margin-top: 20px;
	padding: 15px;
	border-radius: 8px;
	background-color: rgba(52, 152, 219, 0.1);
}

.info h3 {
	margin-top: 0;
}

@media (prefers-color-scheme: dark) {
	body {
		background-color: #121212;
		color: #e0e0e0;
	}

	canvas {
		background-color: rgba(255, 255, 255, 0.05);
	}

	select,
	input {
		background-color: #2a2a2a;
		border-color: #444;
		color: #e0e0e0;
	}

	button {
		background-color: #2980b9;
	}

	button:hover {
		background-color: #3498db;
	}

	.info {
		background-color: rgba(52, 152, 219, 0.2);
	}
}
---js
document.addEventListener("DOMContentLoaded", function () {
  let audioContext;
  let analyser;
  let source;
  let isPlaying = false;
  let isUsingMic = false;
  let oscillator;

  const micButton = document.getElementById("mic-button");
  const oscillatorButton = document.getElementById("oscillator-button");
  const oscillatorControls = document.getElementById("oscillator-controls");
  const waveformSelect = document.getElementById("waveform");
  const frequencySlider = document.getElementById("frequency");
  const frequencyValue = document.getElementById("frequency-value");
  const fftSizeSelect = document.getElementById("fft-size");
  const smoothingSlider = document.getElementById("smoothing");
  const smoothingValue = document.getElementById("smoothing-value");
  const frequencyCanvas = document.getElementById("frequency-canvas");
  const waveformCanvas = document.getElementById("waveform-canvas");
  const audioInfo = document.getElementById("audio-info");

  const freqCtx = frequencyCanvas.getContext("2d");
  const waveCtx = waveformCanvas.getContext("2d");

  // Resize canvases to match display size
  function resizeCanvases() {
    [frequencyCanvas, waveformCanvas].forEach(canvas => {
      canvas.width = canvas.clientWidth * window.devicePixelRatio;
      canvas.height = canvas.clientHeight * window.devicePixelRatio;
    });
  }

  // Output level: the oscillator is played quietly; the microphone is never played
  // back through the speakers (that would make a loud feedback howl).
  let outputGain = null;

  // Initialize audio context and analyzer
  function initAudio() {
    if (audioContext) return;

    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    analyser = audioContext.createAnalyser();

    // Set initial analyser properties
    analyser.fftSize = parseInt(fftSizeSelect.value);
    analyser.smoothingTimeConstant = parseFloat(smoothingSlider.value);

    // Connect analyser to destination through a quiet output gain
    outputGain = audioContext.createGain();
    outputGain.gain.value = 0.2;
    analyser.connect(outputGain);
    outputGain.connect(audioContext.destination);
  }

  // Update analyser settings
  function updateAnalyserSettings() {
    if (!analyser) return;

    analyser.fftSize = parseInt(fftSizeSelect.value);
    analyser.smoothingTimeConstant = parseFloat(smoothingSlider.value);
  }

  // Start microphone input
  async function startMicrophone() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

      // Initialize if needed
      initAudio();
      outputGain.gain.value = 0; // microphone: analyze it, but do not play it

      // Disconnect any existing source
      if (source) {
        source.disconnect();
      }

      if (oscillator) {
        oscillator.stop();
        oscillator = null;
      }

      // Create and connect microphone source
      source = audioContext.createMediaStreamSource(stream);
      source.connect(analyser);

      isPlaying = true;
      isUsingMic = true;
      micButton.textContent = "Microphone Active";
      oscillatorButton.textContent = "Use Oscillator";
      oscillatorControls.style.display = "none";

      // Start visualization
      startVisualization();

      // Update info
      updateAudioInfo();
    } catch (error) {
      console.error("Error accessing microphone:", error);
      alert(
      "Could not access microphone. Please check permissions and try again.");

    }
  }

  // Start oscillator
  function startOscillator() {
    // Initialize if needed
    initAudio();

    // Disconnect any existing source
    if (source) {
      source.disconnect();
    }

    if (oscillator) {
      oscillator.stop();
    }

    // Create and connect oscillator
    oscillator = audioContext.createOscillator();
    oscillator.type = waveformSelect.value;
    oscillator.frequency.value = parseInt(frequencySlider.value);

    source = oscillator;
    oscillator.connect(analyser);
    outputGain.gain.value = 0.2; // oscillator: quiet

    // Start oscillator
    oscillator.start();

    isPlaying = true;
    isUsingMic = false;
    oscillatorButton.textContent = "Oscillator Active";
    micButton.textContent = "Use Microphone";
    oscillatorControls.style.display = "block";

    // Start visualization
    startVisualization();

    // Update info
    updateAudioInfo();
  }

  // Stop all audio
  function stopAudio() {
    if (!isPlaying) return;

    if (source) {
      source.disconnect();
    }

    if (oscillator) {
      oscillator.stop();
      oscillator = null;
    }

    isPlaying = false;
    micButton.textContent = "Use Microphone";
    oscillatorButton.textContent = "Use Oscillator";

    // Stop visualization
    cancelAnimationFrame(animationId);
  }

  // Draw frequency spectrum
  function drawFrequencySpectrum() {
    const width = frequencyCanvas.width;
    const height = frequencyCanvas.height;

    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);

    analyser.getByteFrequencyData(dataArray);

    freqCtx.clearRect(0, 0, width, height);

    const barWidth = width / bufferLength;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
      const percent = dataArray[i] / 255;
      const barHeight = height * percent;

      // Create gradient coloring based on frequency
      const hue = i / bufferLength * 360;
      freqCtx.fillStyle = `hsl(${hue}, 100%, 50%)`;

      freqCtx.fillRect(x, height - barHeight, barWidth, barHeight);

      x += barWidth;
    }
  }

  // Draw waveform
  function drawWaveform() {
    const width = waveformCanvas.width;
    const height = waveformCanvas.height;

    const bufferLength = analyser.fftSize;
    const dataArray = new Uint8Array(bufferLength);

    analyser.getByteTimeDomainData(dataArray);

    waveCtx.clearRect(0, 0, width, height);

    waveCtx.lineWidth = 2;
    waveCtx.strokeStyle = "#00FFFF";
    waveCtx.beginPath();

    const sliceWidth = width / bufferLength;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
      const v = dataArray[i] / 128.0;
      const y = v * height / 2;

      if (i === 0) {
        waveCtx.moveTo(x, y);
      } else {
        waveCtx.lineTo(x, y);
      }

      x += sliceWidth;
    }

    waveCtx.lineTo(width, height / 2);
    waveCtx.stroke();
  }

  // Calculate and display dominant frequency
  function updateAudioInfo() {
    if (!analyser || !isPlaying) {
      audioInfo.textContent = "Connect an audio source to see analysis data.";
      return;
    }

    // Get frequency data
    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);
    analyser.getByteFrequencyData(dataArray);

    // Find the maximum
    let maxIndex = 0;
    let maxValue = 0;
    let total = 0;
    let count = 0;

    for (let i = 0; i < bufferLength; i++) {
      total += dataArray[i];

      if (dataArray[i] > 0) {
        count++;
      }

      if (dataArray[i] > maxValue) {
        maxIndex = i;
        maxValue = dataArray[i];
      }
    }

    // Convert bin index to frequency (Hz)
    const dominantFrequency =
    maxIndex * audioContext.sampleRate / analyser.fftSize;
    const averageEnergy = count > 0 ? total / count : 0;

    // Format info text
    let infoText = "";

    if (isUsingMic) {
      infoText += "Source: Microphone\n";
    } else {
      infoText += `Source: Oscillator (${waveformSelect.value} wave @ ${frequencySlider.value} Hz)\n`;
    }

    infoText += `Sample Rate: ${audioContext.sampleRate} Hz\n`;
    infoText += `FFT Size: ${analyser.fftSize} (${analyser.frequencyBinCount} bins)\n`;
    infoText += `Dominant Frequency: ${dominantFrequency.toFixed(1)} Hz\n`;
    infoText += `Average Energy: ${averageEnergy.toFixed(1)} / 255`;

    audioInfo.textContent = infoText;
  }

  let animationId;

  // Start visualization loop
  function startVisualization() {
    function visualize() {
      drawFrequencySpectrum();
      drawWaveform();
      updateAudioInfo();

      animationId = requestAnimationFrame(visualize);
    }

    visualize();
  }

  // Event listeners
  micButton.addEventListener("click", function () {
    if (isPlaying && isUsingMic) {
      stopAudio();
    } else {
      startMicrophone();
    }
  });

  oscillatorButton.addEventListener("click", function () {
    if (isPlaying && !isUsingMic) {
      stopAudio();
    } else {
      startOscillator();
    }
  });

  waveformSelect.addEventListener("change", function () {
    if (oscillator) {
      oscillator.type = this.value;
      updateAudioInfo();
    }
  });

  frequencySlider.addEventListener("input", function () {
    frequencyValue.textContent = this.value;

    if (oscillator) {
      oscillator.frequency.value = parseInt(this.value);
      updateAudioInfo();
    }
  });

  fftSizeSelect.addEventListener("change", updateAnalyserSettings);

  smoothingSlider.addEventListener("input", function () {
    smoothingValue.textContent = this.value;
    updateAnalyserSettings();
  });

  // Handle window resize
  window.addEventListener("resize", resizeCanvases);

  // Initial setup
  resizeCanvases();
});

Integrating with MIDI and External Hardware#

Connecting to MIDI Devices#

MIDI is a simple message format that instruments use to say "key 60 pressed, this hard". A message is three numbers (bytes): a status byte (the type and channel; 0x90 is "note on, channel 1"), the note number (60 is middle C, 69 is A4, and each +1 is one semitone), and the velocity from 0 to 127 (how hard the key was hit). The Web MIDI API (navigator.requestMIDIAccess()) delivers those messages to your page. It needs a secure context (an https:// page or http://localhost), the browser may ask for permission, and not every browser supports it. Firefox has had it since version 108 and Chrome and Edge have it, but Safari does not (caniuse, October 2026). Chrome has asked for permission on every Web MIDI request since version 124. Without a MIDI keyboard you can still try the message handling, because a message is only three numbers:

MIDI messages without any hardware
---html
<button id="play">Play three MIDI notes</button>
---js
let ctx = null;
// Each message is [status, note, velocity]. 0x90 means "note on".
const messages = [[0x90, 60, 100], [0x90, 64, 100], [0x90, 67, 100]];
// TODO: add [0x90, 72, 100] to the end, then run it again

function noteOn(note, velocity, when) {
  const frequency = 440 * Math.pow(2, (note - 69) / 12);  // MIDI note -> Hz
  const osc = ctx.createOscillator();
  osc.type = 'triangle';
  osc.frequency.value = frequency;
  const volume = ctx.createGain();
  volume.gain.value = (velocity / 127) * 0.3;              // harder hit = louder, but gentle
  osc.connect(volume);
  volume.connect(ctx.destination);
  osc.start(when);
  osc.stop(when + 0.4);
  console.log('note ' + note + ' -> ' + frequency.toFixed(1) + ' Hz');
}

document.getElementById('play').onclick = () => {
  const Ctx = window.AudioContext || window.webkitAudioContext;
  if (!ctx) ctx = new Ctx();
  if (ctx.state === 'suspended') ctx.resume();
  messages.forEach(([status, note, velocity], i) => {
    if ((status >> 4) === 0x9 && velocity > 0) noteOn(note, velocity, ctx.currentTime + i * 0.5);
  });
};

What you should hear: three soft notes half a second apart, going up: C, E, G, the notes of a C major chord played one at a time. The Console prints the frequency of each (261.6 Hz for note 60). With the fourth message added, the arpeggio ends on a higher C, an octave above the first.

Now the real thing, which hands you the same messages from a connected device:

class MidiController {
  constructor() {
    this.inputs = [];
    this.outputs = [];
    this.onNoteOn = null;
    this.onNoteOff = null;
    this.onControlChange = null;
  }

  async initialize() {
    try {
      const midiAccess = await navigator.requestMIDIAccess();

      // Get inputs and outputs
      this.inputs = Array.from(midiAccess.inputs.values());
      this.outputs = Array.from(midiAccess.outputs.values());

      // Set up connection state change listener
      midiAccess.addEventListener('statechange', this.handleStateChange.bind(this));

      // Set up message listeners
      this.inputs.forEach(input => {
        input.addEventListener('midimessage', this.handleMidiMessage.bind(this));
      });

      return true;
    } catch (error) {
      console.error('MIDI access denied:', error);
      return false;
    }
  }

  handleStateChange(event) {
    console.log('MIDI connection state change:', event.port.name, event.port.state);
  }

  handleMidiMessage(event) {
    const [status, data1, data2] = event.data;

    // Determine message type from status byte
    const messageType = status >> 4;
    const channel = status & 0xF;

    switch (messageType) {
      case 0x9: // Note On (144-159)
        if (data2 > 0 && this.onNoteOn) {
          this.onNoteOn(data1, data2, channel, event);
        } else if (data2 === 0 && this.onNoteOff) {
          // Note On with velocity 0 is equivalent to Note Off
          this.onNoteOff(data1, data2, channel, event);
        }
        break;

      case 0x8: // Note Off (128-143)
        if (this.onNoteOff) {
          this.onNoteOff(data1, data2, channel, event);
        }
        break;

      case 0xB: // Control Change (176-191)
        if (this.onControlChange) {
          this.onControlChange(data1, data2, channel, event);
        }
        break;

      // Handle other message types as needed
    }
  }

  sendNoteOn(note, velocity = 64, channel = 0) {
    this.outputs.forEach(output => {
      output.send([0x90 | channel, note, velocity]);
    });
  }

  sendNoteOff(note, velocity = 0, channel = 0) {
    this.outputs.forEach(output => {
      output.send([0x80 | channel, note, velocity]);
    });
  }

  sendControlChange(controller, value, channel = 0) {
    this.outputs.forEach(output => {
      output.send([0xB0 | channel, controller, value]);
    });
  }
}

Building a MIDI Synthesizer#

Let's create a synthesizer controlled by MIDI:

class MidiSynthesizer {
  constructor(audioContext) {
    this.audioContext = audioContext;
    this.activeOscillators = new Map();
    this.masterGain = audioContext.createGain();
    this.masterGain.gain.value = 0.5;
    this.masterGain.connect(audioContext.destination);

    // Synth parameters
    this.waveform = 'sawtooth';
    this.attackTime = 0.05;
    this.releaseTime = 0.1;

    // Create MIDI controller
    this.midiController = new MidiController();
    this.setupMidiHandling();
  }

  async initialize() {
    return this.midiController.initialize();
  }

  setupMidiHandling() {
    this.midiController.onNoteOn = (note, velocity, channel) => {
      this.noteOn(note, velocity / 127);
    };

    this.midiController.onNoteOff = (note) => {
      this.noteOff(note);
    };

    this.midiController.onControlChange = (controller, value) => {
      // Handle CC messages - example: mod wheel
      if (controller === 1) { // Mod wheel
        // Apply modulation effect
      }
    };
  }

  noteToFrequency(note) {
    // A4 (MIDI note 69) = 440Hz
    return 440 * Math.pow(2, (note - 69) / 12);
  }

  noteOn(note, velocity = 0.7) {
    // If note is already playing, stop it first
    if (this.activeOscillators.has(note)) {
      this.noteOff(note);
    }

    const frequency = this.noteToFrequency(note);

    // Create oscillator
    const oscillator = this.audioContext.createOscillator();
    oscillator.type = this.waveform;
    oscillator.frequency.value = frequency;

    // Create envelope
    const envelope = this.audioContext.createGain();
    envelope.gain.value = 0;

    // Connect nodes
    oscillator.connect(envelope);
    envelope.connect(this.masterGain);

    // Apply attack
    envelope.gain.setValueAtTime(0, this.audioContext.currentTime);
    envelope.gain.linearRampToValueAtTime(
      velocity, 
      this.audioContext.currentTime + this.attackTime
    );

    // Start oscillator
    oscillator.start();

    // Store active oscillator and its envelope
    this.activeOscillators.set(note, { oscillator, envelope });
  }

  noteOff(note) {
    const activeNote = this.activeOscillators.get(note);
    if (!activeNote) return;

    const { oscillator, envelope } = activeNote;
    const releaseEnd = this.audioContext.currentTime + this.releaseTime;

    // Apply release envelope
    envelope.gain.setValueAtTime(envelope.gain.value, this.audioContext.currentTime);
    envelope.gain.linearRampToValueAtTime(0, releaseEnd);

    // Stop oscillator after release
    oscillator.stop(releaseEnd);

    // Remove from active notes after release
    setTimeout(() => {
      this.activeOscillators.delete(note);
    }, this.releaseTime * 1000);
  }

  setWaveform(waveform) {
    this.waveform = waveform;
  }

  setAttack(time) {
    this.attackTime = time;
  }

  setRelease(time) {
    this.releaseTime = time;
  }
}
Web MIDI Synthesizer
---html
<div class="container">
	<h1>Web MIDI Synthesizer</h1>
	<p>Connect a MIDI device or use the on-screen keyboard to play sounds with Web Audio API</p>

	<div class="controls">
		<div class="control-group">
			<h2>MIDI Input</h2>
			<button id="enable-midi">Enable MIDI</button>

			<div id="midi-status">MIDI not initialized</div>

			<label for="midi-input" id="midi-input-label" style="display: none;">MIDI Input Device:</label>
			<select id="midi-input" style="display: none;"></select>
		</div>

		<div class="control-group">
			<h2>Synthesizer Settings</h2>

			<label for="waveform">Waveform:</label>
			<select id="waveform">
				<option value="sine">Sine</option>
				<option value="square">Square</option>
				<option value="sawtooth" selected>Sawtooth</option>
				<option value="triangle">Triangle</option>
			</select>

			<label for="attack">Attack: <span id="attack-value">0.05</span>s</label>
			<input type="range" id="attack" min="0.01" max="1" step="0.01" value="0.05">

			<label for="release">Release: <span id="release-value">0.2</span>s</label>
			<input type="range" id="release" min="0.01" max="1" step="0.01" value="0.2">

			<label for="filter-cutoff">Filter Cutoff: <span id="filter-cutoff-value">2000</span> Hz</label>
			<input type="range" id="filter-cutoff" min="60" max="10000" step="1" value="2000">

			<label for="filter-resonance">Filter Resonance: <span id="filter-resonance-value">2</span></label>
			<input type="range" id="filter-resonance" min="0.1" max="20" step="0.1" value="2">
		</div>
	</div>

	<div class="piano" id="piano">
		<!-- Keys will be added dynamically -->
	</div>

	<h2>MIDI Messages</h2>
	<div class="midi-message" id="midi-messages"></div>
</div>
---css
:root {
	color-scheme: light dark;
}

body {
	font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
		sans-serif;
	max-width: 900px;
	margin: 0 auto;
	padding: 20px;
	line-height: 1.6;
}

.container {
	display: flex;
	flex-direction: column;
	align-items: center;
}

.controls {
	margin: 20px 0;
	display: flex;
	flex-wrap: wrap;
	gap: 15px;
	width: 100%;
}

.control-group {
	flex: 1;
	min-width: 250px;
	padding: 15px;
	border-radius: 8px;
	background-color: rgba(0, 0, 0, 0.05);
}

.piano {
	width: 100%;
	height: 200px;
	margin: 20px 0;
	position: relative;
}

.key {
	position: absolute;
	height: 100%;
	border-radius: 0 0 5px 5px;
	cursor: pointer;
	box-sizing: border-box;
}

.white-key {
	background-color: white;
	border: 1px solid #aaa;
	z-index: 1;
}

.white-key.active {
	background-color: #ccc;
}

.black-key {
	background-color: #222;
	height: 65%;
	z-index: 2;
}

.black-key.active {
	background-color: #555;
}

.note-label {
	position: absolute;
	bottom: 10px;
	left: 0;
	right: 0;
	text-align: center;
	font-size: 12px;
	color: #333;
}

.black-key .note-label {
	color: #fff;
	bottom: 5px;
}

.midi-message {
	width: 100%;
	height: 150px;
	overflow-y: auto;
	background-color: rgba(0, 0, 0, 0.05);
	padding: 10px;
	border-radius: 5px;
	font-family: monospace;
	margin-top: 20px;
}

.midi-message-entry {
	margin: 5px 0;
	padding: 5px;
	border-radius: 3px;
}

.midi-message-entry.note-on {
	background-color: rgba(46, 204, 113, 0.2);
}

.midi-message-entry.note-off {
	background-color: rgba(231, 76, 60, 0.2);
}

.midi-message-entry.cc {
	background-color: rgba(52, 152, 219, 0.2);
}

button {
	padding: 8px 15px;
	background-color: #3498db;
	color: white;
	border: none;
	border-radius: 4px;
	cursor: pointer;
	margin: 5px 0;
}

button:hover {
	background-color: #2980b9;
}

label {
	display: block;
	margin-bottom: 5px;
}

select,
input {
	width: 100%;
	padding: 8px;
	border-radius: 4px;
	border: 1px solid #ddd;
	margin-bottom: 10px;
}

@media (prefers-color-scheme: dark) {
	body {
		background-color: #121212;
		color: #e0e0e0;
	}

	.control-group {
		background-color: rgba(255, 255, 255, 0.05);
	}

	.midi-message {
		background-color: rgba(255, 255, 255, 0.05);
		color: #e0e0e0;
	}

	.white-key {
		background-color: #e0e0e0;
	}

	.white-key .note-label {
		color: #333;
	}

	.white-key.active {
		background-color: #aaa;
	}

	.black-key {
		background-color: #222;
	}

	.black-key.active {
		background-color: #555;
	}

	select,
	input {
		background-color: #2a2a2a;
		border-color: #444;
		color: #e0e0e0;
	}

	button {
		background-color: #2980b9;
	}

	button:hover {
		background-color: #3498db;
	}

	.midi-message-entry.note-on {
		background-color: rgba(46, 204, 113, 0.3);
	}

	.midi-message-entry.note-off {
		background-color: rgba(231, 76, 60, 0.3);
	}

	.midi-message-entry.cc {
		background-color: rgba(52, 152, 219, 0.3);
	}
}
---js
document.addEventListener("DOMContentLoaded", function () {
  let audioContext;
  let midiAccess;
  let activeInput = null;
  let activeNotes = new Map();
  let synth;

  const enableMidiButton = document.getElementById("enable-midi");
  const midiStatusElement = document.getElementById("midi-status");
  const midiInputSelect = document.getElementById("midi-input");
  const midiInputLabel = document.getElementById("midi-input-label");
  const waveformSelect = document.getElementById("waveform");
  const attackSlider = document.getElementById("attack");
  const attackValue = document.getElementById("attack-value");
  const releaseSlider = document.getElementById("release");
  const releaseValue = document.getElementById("release-value");
  const filterCutoffSlider = document.getElementById("filter-cutoff");
  const filterCutoffValue = document.getElementById("filter-cutoff-value");
  const filterResonanceSlider = document.getElementById("filter-resonance");
  const filterResonanceValue = document.getElementById("filter-resonance-value");
  const pianoElement = document.getElementById("piano");
  const midiMessagesElement = document.getElementById("midi-messages");

  // Piano key definitions - with exact positioning information
  // Format: [note, type, label, startPosition(%), width(%)]
  const keys = [
  [60, "white", "C3", 0, 7], // C3
  [61, "black", "C#3", 5, 4], // C#3
  [62, "white", "D3", 7, 7], // D3
  [63, "black", "D#3", 12, 4], // D#3
  [64, "white", "E3", 14, 7], // E3
  [65, "white", "F3", 21, 7], // F3
  [66, "black", "F#3", 26, 4], // F#3
  [67, "white", "G3", 28, 7], // G3
  [68, "black", "G#3", 33, 4], // G#3
  [69, "white", "A3", 35, 7], // A3
  [70, "black", "A#3", 40, 4], // A#3
  [71, "white", "B3", 42, 7], // B3
  [72, "white", "C4", 49, 7], // C4
  [73, "black", "C#4", 54, 4], // C#4
  [74, "white", "D4", 56, 7], // D4
  [75, "black", "D#4", 61, 4], // D#4
  [76, "white", "E4", 63, 7], // E4
  [77, "white", "F4", 70, 7], // F4
  [78, "black", "F#4", 75, 4], // F#4
  [79, "white", "G4", 77, 7], // G4
  [80, "black", "G#4", 82, 4], // G#4
  [81, "white", "A4", 84, 7], // A4
  [82, "black", "A#4", 89, 4], // A#4
  [83, "white", "B4", 91, 7] // B4
  ];

  // Synthesizer class
  class Synthesizer {
    constructor(audioContext) {
      this.audioContext = audioContext;
      this.activeNotes = new Map();
    }

    // Convert MIDI note number to frequency
    noteToFrequency(note) {
      return 440.0 * Math.pow(2, (note - 69) / 12);
    }

    playNote(note, velocity = 127) {
      const frequency = this.noteToFrequency(note);

      // If note is already playing, stop it first
      if (this.activeNotes.has(note)) {
        this.stopNote(note);
      }

      // Create oscillator
      const oscillator = this.audioContext.createOscillator();
      oscillator.type = waveformSelect.value;
      oscillator.frequency.value = frequency;

      // Create filter
      const filter = this.audioContext.createBiquadFilter();
      filter.type = "lowpass";
      filter.frequency.value = parseFloat(filterCutoffSlider.value);
      filter.Q.value = parseFloat(filterResonanceSlider.value);

      // Create gain node for envelope
      const gainNode = this.audioContext.createGain();
      gainNode.gain.value = 0;

      // Connect nodes
      oscillator.connect(filter);
      filter.connect(gainNode);
      gainNode.connect(this.audioContext.destination);

      // Apply attack
      const attackTime = parseFloat(attackSlider.value);
      const now = this.audioContext.currentTime;

      gainNode.gain.setValueAtTime(0, now);
      gainNode.gain.linearRampToValueAtTime(velocity / 127, now + attackTime);

      // Start oscillator
      oscillator.start();

      // Store active note
      this.activeNotes.set(note, {
        oscillator,
        gainNode,
        filter,
        velocity });


      // Update UI
      const keyElement = document.querySelector(`.key[data-note="${note}"]`);
      if (keyElement) {
        keyElement.classList.add("active");
      }
    }

    stopNote(note) {
      const activeNote = this.activeNotes.get(note);
      if (!activeNote) return;

      const { oscillator, gainNode } = activeNote;

      // Apply release
      const releaseTime = parseFloat(releaseSlider.value);
      const now = this.audioContext.currentTime;

      gainNode.gain.setValueAtTime(gainNode.gain.value, now);
      gainNode.gain.linearRampToValueAtTime(0, now + releaseTime);

      // Stop oscillator after release
      oscillator.stop(now + releaseTime);

      // Remove from active notes after release
      setTimeout(() => {
        this.activeNotes.delete(note);
      }, releaseTime * 1000);

      // Update UI
      const keyElement = document.querySelector(`.key[data-note="${note}"]`);
      if (keyElement) {
        keyElement.classList.remove("active");
      }
    }

    // Update filter settings for all active notes
    updateFilter(frequency, resonance) {
      this.activeNotes.forEach(note => {
        if (note.filter) {
          note.filter.frequency.value = frequency;
          note.filter.Q.value = resonance;
        }
      });
    }}


  // Create piano keyboard
  function createPiano() {
    // Clear the piano
    pianoElement.innerHTML = "";

    // Get piano dimensions
    const pianoWidth = pianoElement.clientWidth;
    const pianoHeight = pianoElement.clientHeight;

    // Create all keys
    keys.forEach(key => {
      const [note, type, label, position, width] = key;

      const keyElement = document.createElement("div");
      keyElement.className = `key ${type}-key`;
      keyElement.dataset.note = note;

      // Set key position and size based on relative percentages
      keyElement.style.left = `${position}%`;
      keyElement.style.width = `${width}%`;

      // Add note label
      const labelElement = document.createElement("div");
      labelElement.className = "note-label";
      labelElement.textContent = label;
      keyElement.appendChild(labelElement);

      // Add event listeners for mouse interaction
      keyElement.addEventListener("mousedown", () => {
        if (!audioContext) initAudio();
        synth.playNote(note);
        logMidiMessage("note-on", note, 127);
      });

      keyElement.addEventListener("mouseup", () => {
        if (!synth) return;
        synth.stopNote(note);
        logMidiMessage("note-off", note, 0);
      });

      keyElement.addEventListener("mouseleave", () => {
        if (!synth) return;
        if (synth.activeNotes.has(note)) {
          synth.stopNote(note);
        }
      });

      pianoElement.appendChild(keyElement);
    });
  }

  // Initialize audio context and synthesizer
  function initAudio() {
    if (audioContext) return;

    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    synth = new Synthesizer(audioContext);
  }

  // Log MIDI message to UI
  function logMidiMessage(type, data1, data2) {
    const entry = document.createElement("div");
    entry.classList.add("midi-message-entry", type);

    const timestamp = new Date().toISOString().substr(11, 12);

    let message = `[${timestamp}] `;

    switch (type) {
      case "note-on":
        message += `Note On: ${midiNoteToName(
        data1)
        } (${data1}) Velocity: ${data2}`;
        break;
      case "note-off":
        message += `Note Off: ${midiNoteToName(data1)} (${data1})`;
        break;
      case "cc":
        message += `Control Change: Controller ${data1}, Value: ${data2}`;
        break;
      default:
        message += `${type}: ${data1}, ${data2}`;}


    entry.textContent = message;
    midiMessagesElement.prepend(entry);

    // Limit number of messages
    while (midiMessagesElement.children.length > 50) {
      midiMessagesElement.removeChild(midiMessagesElement.lastChild);
    }
  }

  // Convert MIDI note number to note name
  function midiNoteToName(note) {
    const noteNames = [
    "C",
    "C#",
    "D",
    "D#",
    "E",
    "F",
    "F#",
    "G",
    "G#",
    "A",
    "A#",
    "B"];

    const octave = Math.floor(note / 12) - 1;
    const noteName = noteNames[note % 12];
    return `${noteName}${octave}`;
  }

  // Initialize MIDI
  async function initMIDI() {
    if (!navigator.requestMIDIAccess) {
      midiStatusElement.textContent = "Web MIDI API not supported in this browser";
      return false;
    }

    try {
      midiAccess = await navigator.requestMIDIAccess();

      // Update status
      midiStatusElement.textContent =
      "MIDI enabled. " + midiAccess.inputs.size + " input(s) detected.";

      // Show input selector if inputs exist
      if (midiAccess.inputs.size > 0) {
        midiInputSelect.style.display = "block";
        midiInputLabel.style.display = "block";

        // Clear existing options
        while (midiInputSelect.firstChild) {
          midiInputSelect.removeChild(midiInputSelect.firstChild);
        }

        // Add default option
const defaultOption = document.createElement("option");
        defaultOption.value = "";
        defaultOption.textContent = "-- Select MIDI Input --";
        midiInputSelect.appendChild(defaultOption);

        // Add input options
        midiAccess.inputs.forEach((input, id) => {
          const option = document.createElement("option");
          option.value = id;
          option.textContent = input.name || `Input ${id}`;
          midiInputSelect.appendChild(option);
        });
      }

      // Handle connection changes
      midiAccess.addEventListener("statechange", handleStateChange);

      return true;
    } catch (error) {
      console.error("MIDI access denied:", error);
      midiStatusElement.textContent =
      "MIDI access denied. Check browser permissions.";
      return false;
    }
  }

  // Handle MIDI state changes
  function handleStateChange(event) {
    const port = event.port;
    console.log(`MIDI port ${port.name} state changed to ${port.state}`);

    // Re-init MIDI
    initMIDI();

    // If the active input disconnected, clear it
    if (
    activeInput &&
    activeInput.id === port.id &&
    port.state === "disconnected")
    {
      setActiveInput(null);
      midiInputSelect.value = "";
    }
  }

  // Set active MIDI input
  function setActiveInput(inputId) {
    // Disconnect previous input
    if (activeInput) {
      activeInput.removeEventListener("midimessage", handleMIDIMessage);
    }

    // Clear active input
    activeInput = null;

    // If no input ID provided, return
    if (!inputId) {
      midiStatusElement.textContent = "No MIDI input selected.";
      return;
    }

    // Set new input
    const input = midiAccess.inputs.get(inputId);
    if (input) {
      activeInput = input;
      input.addEventListener("midimessage", handleMIDIMessage);
      midiStatusElement.textContent = `Active input: ${input.name || inputId}`;
    } else {
      midiStatusElement.textContent = "Invalid MIDI input selected.";
    }
  }

  // Handle MIDI messages
  function handleMIDIMessage(event) {
    const [status, data1, data2] = event.data;

    // Extract message type and channel
    const messageType = status >> 4;
    const channel = status & 0xf;

    // Initialize audio if not already done
    if (!audioContext) {
      initAudio();
    }

    // Handle different message types
    switch (messageType) {
      case 0x9: // Note On (0x90-0x9F)
        if (data2 > 0) {
          synth.playNote(data1, data2);
          logMidiMessage("note-on", data1, data2);
        } else {
          // Note On with velocity 0 is equivalent to Note Off
          synth.stopNote(data1);
          logMidiMessage("note-off", data1, 0);
        }
        break;

      case 0x8: // Note Off (0x80-0x8F)
        synth.stopNote(data1);
        logMidiMessage("note-off", data1, data2);
        break;

      case 0xb: // Control Change (0xB0-0xBF)
        // Handle common CC messages
        switch (data1) {
          case 1: // Modulation wheel
            logMidiMessage("cc", data1, data2);
            // Map mod wheel (0-127) to filter cutoff
            const normalizedValue = data2 / 127;
            const minLog = Math.log(60);
            const maxLog = Math.log(10000);
            const filterValue = Math.exp(
            minLog + normalizedValue * (maxLog - minLog));


            filterCutoffSlider.value = filterValue;
            filterCutoffValue.textContent = Math.round(filterValue);

            // Apply to all active notes
            if (synth) {
              synth.updateFilter(filterValue, parseFloat(filterResonanceSlider.value));
            }
            break;

          default:
            logMidiMessage("cc", data1, data2);}

        break;

      default:
        // Log other message types
        console.log("MIDI message:", event.data);}

  }

  // Event Listeners
  enableMidiButton.addEventListener("click", async function () {
    initAudio();
    const midiInitialized = await initMIDI();

    if (midiInitialized) {
      this.textContent = "MIDI Enabled";
      this.disabled = true;
    }
  });

  midiInputSelect.addEventListener("change", function () {
    setActiveInput(this.value);
  });

  waveformSelect.addEventListener("change", function () {
    // No need to update anything here - will apply to new notes
  });

  attackSlider.addEventListener("input", function () {
    attackValue.textContent = this.value;
  });

  releaseSlider.addEventListener("input", function () {
    releaseValue.textContent = this.value;
  });

  filterCutoffSlider.addEventListener("input", function () {
    filterCutoffValue.textContent = this.value;

    if (synth) {
      synth.updateFilter(
      parseFloat(this.value),
      parseFloat(filterResonanceSlider.value));

    }
  });

  filterResonanceSlider.addEventListener("input", function () {
    filterResonanceValue.textContent = this.value;

    if (synth) {
      synth.updateFilter(
      parseFloat(filterCutoffSlider.value),
      parseFloat(this.value));

    }
  });

  // Window resize handler
  window.addEventListener("resize", function () {
    // Recreate piano on window resize
    createPiano();
  });

  // Create piano on load
  createPiano();
});

Building a Mixing Console#

Creating a Multi-track Mixing Console#

Let us build a mixing console:

class AudioTrack {
  constructor(audioContext, name = 'Track') {
    this.audioContext = audioContext;
    this.name = name;

    // Track routing nodes
    this.input = audioContext.createGain(); // Track input
    this.output = audioContext.createGain(); // Track output
    this.fader = audioContext.createGain(); // Volume fader
    this.panner = audioContext.createStereoPanner(); // Pan control

    // Effects
    this.eqLow = audioContext.createBiquadFilter();
    this.eqLow.type = 'lowshelf';
    this.eqLow.frequency.value = 250;
    this.eqLow.gain.value = 0;

    this.eqMid = audioContext.createBiquadFilter();
    this.eqMid.type = 'peaking';
    this.eqMid.frequency.value = 1000;
    this.eqMid.Q.value = 1;
    this.eqMid.gain.value = 0;

    this.eqHigh = audioContext.createBiquadFilter();
    this.eqHigh.type = 'highshelf';
    this.eqHigh.frequency.value = 4000;
    this.eqHigh.gain.value = 0;

    // Sends
    this.sends = new Map();

    // Connect the main signal path
    this.input
      .connect(this.eqLow)
      .connect(this.eqMid)
      .connect(this.eqHigh)
      .connect(this.panner)
      .connect(this.fader)
      .connect(this.output);

    // Initial settings
    this.fader.gain.value = 0.75;
    this.panner.pan.value = 0;
    this.muted = false;
    this.soloed = false;
  }

  // Volume control (0-1)
  setVolume(value) {
    this.fader.gain.linearRampToValueAtTime(
      value, 
      this.audioContext.currentTime + 0.01
    );
  }

  // Pan control (-1 to 1)
  setPan(value) {
    this.panner.pan.linearRampToValueAtTime(
      value,
      this.audioContext.currentTime + 0.01
    );
  }

  // EQ controls
  setLowEQ(gain) {
    this.eqLow.gain.value = gain;
  }

  setMidEQ(gain) {
    this.eqMid.gain.value = gain;
  }

  setHighEQ(gain) {
    this.eqHigh.gain.value = gain;
  }

  // Mute control
  setMute(mute) {
    this.muted = mute;
    this.fader.gain.linearRampToValueAtTime(
      mute ? 0 : this.unmutedVolume || 0.75,
      this.audioContext.currentTime + 0.01
    );

    if (mute) {
      this.unmutedVolume = this.fader.gain.value;
    }
  }

  // Add a send to an effects bus
  addSend(name, destination, level = 0.5) {
    const sendGain = this.audioContext.createGain();
    sendGain.gain.value = level;

    // Connect from pre-fader (after EQ)
    this.eqHigh.connect(sendGain);
    sendGain.connect(destination);

    this.sends.set(name, sendGain);
  }

  // Set send level
  setSendLevel(name, level) {
    const send = this.sends.get(name);
    if (send) {
      send.gain.linearRampToValueAtTime(
        level,
        this.audioContext.currentTime + 0.01
      );
    }
  }
}

class MixingConsole {
  constructor(audioContext) {
    this.audioContext = audioContext;
    this.tracks = new Map();

    // Master bus
    this.masterBus = audioContext.createGain();
    this.masterBus.connect(audioContext.destination);

    // Effects buses (reverb, delay, etc.)
    this.effectsBuses = new Map();

    // Create some standard effects buses
    this.createReverbBus();
    this.createDelayBus();
  }

  createTrack(name) {
    const track = new AudioTrack(this.audioContext, name);
    track.output.connect(this.masterBus);

    // Connect to standard effect sends
    track.addSend('reverb', this.effectsBuses.get('reverb'), 0);
    track.addSend('delay', this.effectsBuses.get('delay'), 0);

    this.tracks.set(name, track);
    return track;
  }

  removeTrack(name) {
    const track = this.tracks.get(name);
    if (track) {
      track.output.disconnect();
      this.tracks.delete(name);
    }
  }

  setMasterVolume(value) {
    this.masterBus.gain.linearRampToValueAtTime(
      value,
      this.audioContext.currentTime + 0.01
    );
  }

  createReverbBus() {
    const reverbBus = this.audioContext.createGain();

    // We'll create a convolver for reverb
    const convolver = this.audioContext.createConvolver();

    // Generate an impulse response algorithmically
    // (or you could load a real IR file)
    this.createImpulseResponse().then(buffer => {
      convolver.buffer = buffer;
    });

    // Connect the reverb chain with a dry/wet mix
    const dryGain = this.audioContext.createGain();
    const wetGain = this.audioContext.createGain();

    dryGain.gain.value = 0.5;
    wetGain.gain.value = 0.5;

    reverbBus.connect(dryGain);
    reverbBus.connect(convolver);
    convolver.connect(wetGain);

    dryGain.connect(this.masterBus);
    wetGain.connect(this.masterBus);

    this.effectsBuses.set('reverb', reverbBus);
  }

  createDelayBus() {
    const delayBus = this.audioContext.createGain();

    // Create a stereo delay effect
    const delayLeft = this.audioContext.createDelay(2.0);
    const delayRight = this.audioContext.createDelay(2.0);
    const feedback = this.audioContext.createGain();

    delayLeft.delayTime.value = 0.25;
    delayRight.delayTime.value = 0.5;
    feedback.gain.value = 0.3;

    // Create a stereo split
    const splitter = this.audioContext.createChannelSplitter(2);
    const merger = this.audioContext.createChannelMerger(2);

    // Connect the delay network
    delayBus.connect(splitter);

    splitter.connect(delayLeft, 0);
    splitter.connect(delayRight, 1);

    delayLeft.connect(merger, 0, 0);
    delayRight.connect(merger, 0, 1);

    // Feedback loop
    merger.connect(feedback);
    feedback.connect(delayLeft);
    feedback.connect(delayRight);

    // Connect to master
    merger.connect(this.masterBus);

    this.effectsBuses.set('delay', delayBus);
  }

  // Create a simple algorithmic impulse response for reverb
  async createImpulseResponse() {
    const sampleRate = this.audioContext.sampleRate;
    const length = 2 * sampleRate; // 2 seconds
    const decay = 2.0;

    const buffer = this.audioContext.createBuffer(2, length, sampleRate);

    // Fill both channels with noise that decays exponentially
    for (let channel = 0; channel < 2; channel++) {
      const channelData = buffer.getChannelData(channel);

      for (let i = 0; i < length; i++) {
        // Random noise
        const white = Math.random() * 2 - 1;

        // Exponential decay
        channelData[i] = white * Math.pow(1 - i / length, decay);
      }
    }

    return buffer;
  }
}

Creating Transport Controls for Audio Projects#

Let's create a transport system for precise control in audio applications:

class TransportController {
  constructor(audioContext) {
    this.audioContext = audioContext;
    this.isPlaying = false;
    this.isPaused = false;
    this.startTime = 0;
    this.pauseTime = 0;
    this.tempo = 120;
    this.timeSignature = { numerator: 4, denominator: 4 };
    this.loopRegion = { start: 0, end: 0, enabled: false };
    this.markers = new Map();

    // Callbacks
    this.onPlay = null;
    this.onPause = null;
    this.onStop = null;
    this.onPositionChange = null;

    // Scheduler
    this.scheduledEvents = [];
    this.nextScheduledEventId = 0;
  }

  // Convert between different time formats
  secondsToBeats(seconds) {
    return seconds / 60 * this.tempo;
  }

  beatsToSeconds(beats) {
    return beats * 60 / this.tempo;
  }

  secondsToMeasures(seconds) {
    const beats = this.secondsToBeats(seconds);
    const beatsPerMeasure = this.timeSignature.numerator;

    return beats / beatsPerMeasure;
  }

  // Transport controls
  play() {
    if (this.isPlaying) return;

    if (this.isPaused) {
      // Resume from pause
      const elapsedTime = this.pauseTime - this.startTime;
      this.startTime = this.audioContext.currentTime - elapsedTime;
      this.isPaused = false;
    } else {
      // Start from beginning or current position
      this.startTime = this.audioContext.currentTime;
    }

    this.isPlaying = true;

    if (this.onPlay) this.onPlay();
    this.scheduleEvents();
    this.updatePosition();
  }

  pause() {
    if (!this.isPlaying || this.isPaused) return;

    this.pauseTime = this.audioContext.currentTime;
    this.isPaused = true;
    this.isPlaying = false;

    if (this.onPause) this.onPause();
  }

  stop() {
    if (!this.isPlaying && !this.isPaused) return;

    this.isPlaying = false;
    this.isPaused = false;
    this.startTime = 0;
    this.pauseTime = 0;

    // Clear all scheduled events
    this.scheduledEvents.forEach(event => {
      if (event.timeoutId) {
        clearTimeout(event.timeoutId);
      }
    });
    this.scheduledEvents = [];

    if (this.onStop) this.onStop();
  }

  // Get current playback position in seconds
  getCurrentTime() {
    if (this.isPaused) {
      return this.pauseTime - this.startTime;
    } else if (this.isPlaying) {
      return this.audioContext.currentTime - this.startTime;
    } else {
      return 0;
    }
  }

  // Seek to a specific position in seconds
  seek(time) {
    if (this.isPlaying) {
      this.startTime = this.audioContext.currentTime - time;
    } else if (this.isPaused) {
      this.pauseTime = this.startTime + time;
    }

    // Reschedule events from new position
    if (this.isPlaying) {
      // Clear existing scheduled events
      this.scheduledEvents.forEach(event => {
        if (event.timeoutId) {
          clearTimeout(event.timeoutId);
        }
      });
      this.scheduledEvents = [];
      this.scheduleEvents();
    }

    if (this.onPositionChange) this.onPositionChange(time);
  }

  // Set loop region
  setLoopRegion(start, end) {
    this.loopRegion.start = start;
    this.loopRegion.end = end;
  }

  // Enable/disable looping
  setLooping(enabled) {
    this.loopRegion.enabled = enabled;
  }

  // Add marker at specific time
  addMarker(name, time) {
    this.markers.set(name, time);
  }

  // Jump to marker
  jumpToMarker(name) {
    const markerTime = this.markers.get(name);
    if (markerTime !== undefined) {
      this.seek(markerTime);
    }
  }

  // Schedule an event at specific time
  scheduleEvent(callback, time) {
    const id = this.nextScheduledEventId++;

    const event = {
      id,
      callback,
      time,
      timeoutId: null
    };

    if (this.isPlaying) {
      const now = this.getCurrentTime();
      const delay = Math.max(0, (time - now) * 1000);

      event.timeoutId = setTimeout(() => {
        callback();
        // Remove from scheduled events
        this.scheduledEvents = this.scheduledEvents.filter(e => e.id !== id);

        // Handle looping
        if (this.loopRegion.enabled && time >= this.loopRegion.end) {
          this.seek(this.loopRegion.start);
        }
      }, delay);
    }

    this.scheduledEvents.push(event);
    return id;
  }

  // Reschedule all events (called when play starts or after seeking)
  scheduleEvents() {
    // Create a copy to avoid modification issues during iteration
    const eventsToSchedule = [...this.scheduledEvents];

    // Clear existing timeouts
    eventsToSchedule.forEach(event => {
      if (event.timeoutId) {
        clearTimeout(event.timeoutId);
        event.timeoutId = null;
      }
    });

    // Reschedule
    const now = this.getCurrentTime();
    eventsToSchedule.forEach(event => {
      if (event.time >= now) {
        const delay = (event.time - now) * 1000;

        event.timeoutId = setTimeout(() => {
          event.callback();
          this.scheduledEvents = this.scheduledEvents.filter(e => e.id !== event.id);

          // Handle looping
          if (this.loopRegion.enabled && this.getCurrentTime() >= this.loopRegion.end) {
            this.seek(this.loopRegion.start);
          }
        }, delay);
      }
    });
  }

  // Periodically update position (for UI)
  updatePosition() {
    if (!this.isPlaying) return;

    const currentTime = this.getCurrentTime();

    if (this.onPositionChange) {
      this.onPositionChange(currentTime);
    }

    // Handle loop region
    if (this.loopRegion.enabled && currentTime >= this.loopRegion.end) {
      this.seek(this.loopRegion.start);
    }

    // Update again in about 16ms (~60fps)
    requestAnimationFrame(() => this.updatePosition());
  }
}

Conclusion: Where Sound Meets Space and Time#

You can now put a sound where you want it (left, right, near, far, circling), fake a Doppler shift because the browser will not, keep time against the audio clock, read the notes of a scale from semitone steps, and draw what a sound looks like.

Three places to take it next: add the circling sound to a small game and move it with the player; hook the metronome's fireDue() callbacks to a flashing beat light; or use the analyser to color a page by what is playing. Spatial audio in a headset (WebXR) is another direction, where the listener's position and orientation come from the headset instead of being fixed at the origin.

Comments require JavaScript.