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 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.listenerstands 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" isz = -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.
---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.
---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;
};
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.
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.
---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.
---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);
}
}
---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).
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:
fftSizeis 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.frequencyBinCountis the number of frequency "bars" you get back: alwaysfftSize / 2. WithfftSize = 2048it is 1024. Each bar coverssampleRate / fftSizeHz; at 48000 Hz and 2048 that is about 23 Hz. A biggerfftSizegives 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.
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());
}
}
---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:
---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;
}
}
---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.