I left you last time with a thing nagging at me, and it's been nagging all week: we've spent this whole audio arc placing pictures all over the screen - left, right, top, bottom, near, far - while our sound just sat there in the dead centre of your skull, flat and mono, like it was glued to the middle of your face. That's weird when you think about it. Real sound has a place. A bird is up and to your left. A car passes from right to left and you turn your head without deciding to. Your two ears are a little direction-finding machine, and we've been ignoring it completely. Today we fix that. Today we give sound somewhere to be :-).
And here's the promise that makes this worth an episode: once a sound has a position, you can move it, and once you can move it, you can lock a moving visual to a moving sound - the thing we spent all of episode 123 chasing, now in three dimensions. A dot drifts across the screen and the noise it makes drifts with it, past your left ear, round the back, out to the right. When that clicks the first time it's genuinly a little spooky. Allez, let's build some space.
Before any code, the trick your head is doing, because we're going to lean on it hard. You have two ears a little apart, and a sound coming from your left hits the left ear first - a tiny fraction of a millisecond sooner - and a touch louder, because your head is in the way of the right ear. Those two clues, the time difference and the level difference, are almost the whole game. Your brain reads them constantly and turns them into a direction. That's it. That's the magic. Recreate those two cues in a pair of headphones and the sound seems to come from out there, not from the speakers.
// the two cues your brain uses to place a sound in the horizontal plane:
const spatialCues = {
ITD: "Interaural Time Difference - the sound hits the near ear SLIGHTLY sooner.",
ILD: "Interaural Level Difference - the far ear is quieter (head blocks it).",
// recreate these two and a mono sound appears to come from a direction.
// add front/back & up/down cues (HRTF) and you get FULL 3D. more on that below.
};
The simplest version of this - just messing with the level in each ear - is panning, and it's where every spatial audio journey starts. So let's start there.
Panning slides a sound along a single left-to-right line by making it louder in one ear and quieter in the other. Tone.js hands you a Panner for exactly this: one value from -1 (hard left) through 0 (centre) to +1 (hard right). It's the most basic spatial tool there is, and it's still the one you'll reach for most.
// Tone.Panner: slide a mono sound left <-> right. -1 = left, 0 = centre, 1 = right.
const panner = new Tone.Panner(0).toDestination();
const synth = new Tone.Synth().connect(panner);
panner.pan.value = -0.8; // way over to the left
synth.triggerAttackRelease("C4", "8n"); // this note sounds from your left
Now the fun bit - pan is a signal, which means we can automate it, and a moving pan is where it stops being a setting and starts being an effect. Sweep it slowly and a sound drifts across you like something walking past. This is the same "automate a parameter over time" idea we've used on filters, opacity, everything - just pointed at position now.
// automate the pan to make a sound DRIFT across the stereo field.
// ramp from full left to full right over 4 seconds - a slow fly-past.
panner.pan.setValueAtTime(-1, Tone.now());
panner.pan.linearRampToValueAtTime(1, Tone.now() + 4);
A tiny warning worth having early: a fast hard pan feels unnatural and can even give people a bit of a headache, because real sounds rarely teleport from ear to ear. Gentle drifts, or small offsets that give each element its own little spot in the mix, read as far more natural. Panning is seasoning, not the whole meal. Makes sense, right?
Here's a lovely thing you can do the very second you understand panning: give every element in a piece its own position, and suddenly a flat wall of sound becomes a little room you can point at. The kick sits dead centre, a shaker whispers off to the right, a pad spreads wide, a plucky lead answers from the left. This is what mixing engineers mean by a "stereo image" - they're literally arranging sounds in space.
// give each voice its own spot in the stereo field = an instant sense of space.
function placed(instrument, panPosition) {
const p = new Tone.Panner(panPosition).toDestination();
instrument.connect(p);
return instrument;
}
const kick = placed(new Tone.MembraneSynth(), 0.0); // centre, anchoring
const shaker = placed(new Tone.NoiseSynth(), 0.6); // off to the right
const lead = placed(new Tone.Synth(), -0.5); // answering from the left
const pad = placed(new Tone.PolySynth(), 0.0); // wide & central bed
Do that and even without any 3D trickery a listener feels the width. It's the cheapest spatial upgrade you can make, and it costs one number per instrument. But left-and-right is only one axis, and we've got a whole 3D world sitting right there from our Three.js episodes. Let's go get the other two dimensions.
This is the jump. Web Audio has a proper 3D audio engine baked in, and Tone.js wraps it as Tone.Panner3D. Instead of one left-right number, a sound now has an actual (x, y, z) position in space - and crucially, there's a listener (that's you, the camera, the head) which also has a position and a facing direction. The engine works out the distance and angle between the two and does all the ITD/ILD maths for you. It is exactly the mental model from our 3D graphics episodes: objects in a scene, a camera looking at them - only now for the ears.
// Tone.Panner3D: a sound with a real position in 3D space.
const source3D = new Tone.Panner3D({
positionX: 5, positionY: 0, positionZ: 0, // 5 units to the right of the listener
panningModel: "HRTF", // "HRTF" = the good, head-aware model (headphones!)
}).toDestination();
const voice = new Tone.Oscillator("A3", "sine").connect(source3D).start();
The panningModel: "HRTF" line is doing something worth understanding. HRTF stands for Head-Related Transfer Function, and it's a fancy name for "a measured recording of how a real human head and ears colour a sound coming from every possible direction". Play a sound through that filter for a given angle and your brain believes it's coming from there - including front-vs-back and up-vs-down, which plain panning can never do. The catch: it only really works on headphones, because on speakers your two ears both hear both channels and the illusion collapses. Worth telling your audience to pop headphones on.
The source is only half of it. The other half is the Listener - the ears. Its position and, just as important, its orientation (which way the head faces) decide how every source is heard. Move the listener or turn its facing vector and the entire soundscape rotates around it. If you've got a first-person camera, you wire the listener straight to it and the audio follows the head for free.
// the listener = the ears. position + facing direction decide how sources are heard.
const listener = Tone.getContext().rawContext.listener;
// place the head at the origin:
listener.positionX.value = 0;
listener.positionY.value = 0;
listener.positionZ.value = 0;
// point the "nose" down -Z (into the screen), "up" along +Y:
listener.forwardX.value = 0; listener.forwardY.value = 0; listener.forwardZ.value = -1;
listener.upX.value = 0; listener.upY.value = 1; listener.upZ.value = 0;
This is the same forward/up vector pair we set on a Three.js camera - because it's the same idea. Wire this listener's position to your camera's position each frame and a sound placed in the 3D scene will pan and fade correctly as you fly around it. Eyes and ears sharing one coordinate system. That's the whole dream of this episode, honestly.
Position gives you direction. Distance gives you depth, and it's the cue that sells "this is a real space" more than anything. A far sound is quieter - obviously - but it also loses its highs (air absorbs treble over distance) and picks up more reverb relative to its dry sound. The PannerNode handles the loudness part with a rolloff model; you get to choose how quickly things fade with distance.
// distance attenuation: how fast a sound fades as it moves away from the listener.
const distant = new Tone.Panner3D({
distanceModel: "inverse", // "linear" | "inverse" | "exponential"
refDistance: 1, // distance at which volume is "full"
rolloffFactor: 1.5, // higher = fades with distance FASTER
maxDistance: 50, // past this, it stops getting quieter
}).toDestination();
The rolloffFactor is your "how big is this space" dial. A low rolloff makes even far things audible - good for a vast open landscape. A high rolloff makes sound drop off fast - good for a tight, intimate, damped room. To really nail depth, pair the distance fade with a lowpass filter that closes as things recede, mimicking how air eats the high frequencies:
// add the "air absorbs treble over distance" cue: farther = duller.
function updateDistanceTone(filter, distance) {
// closer -> bright (high cutoff). farther -> muffled (low cutoff).
const hz = Math.max(400, 8000 - distance * 300); // fade the top end out with range
filter.frequency.rampTo(hz, 0.1);
}
// wire: source -> filter -> panner3D -> destination, and call this each frame
// with the current distance from the listener.
Put the three cues together - level, treble, and reverb amount all shifting with distance - and a sound genuinly seems to sit at a depth in front of you. Your brain has been trained on this since birth; you just have to feed it the cues it already expects.
We met reverb back in episode 118 and called it "putting a sound somewhere". Now we can say what that really means. Reverb is thousands of tiny echoes - the sound bouncing off walls, floor, ceiling - arriving a beat after the direct sound. The shape of those echoes is a fingerprint of the room's size and hardness. A big stone cathedral: long, lush tail. A small carpeted study: short, dead. Your ear decodes room size from reverb without you ever thinking about it.
// reverb encodes ROOM SIZE. decay time = how big/hard the space is.
const smallRoom = new Tone.Reverb({ decay: 0.6, wet: 0.2 }).toDestination(); // tight study
const cathedral = new Tone.Reverb({ decay: 6.0, wet: 0.5 }).toDestination(); // vast stone hall
// the SAME note in two spaces feels like two completely different places:
new Tone.Synth().connect(smallRoom).triggerAttackRelease("C4", "8n");
new Tone.Synth().connect(cathedral).triggerAttackRelease("C4", "8n");
The really powerful move for spatial work is the dry/wet ratio changing with distance. Close sounds are mostly dry (you hear the source directly). Far sounds are mostly wet (you hear more of the room's reflection than the source itself). Cross-fade between a dry path and a reverb path based on distance and you get depth that feels almost physical:
// distance -> dry/wet blend. near = dry & direct, far = washed in room reflection.
const dry = new Tone.Gain(1).toDestination();
const wet = new Tone.Gain(0).connect(new Tone.Reverb(3).toDestination());
source.fan(dry, wet); // send the source to BOTH paths at once
function setDepth(distance) { // distance 0..1, near..far
dry.gain.rampTo(1 - distance, 0.1); // fade the direct sound down as it recedes
wet.gain.rampTo(distance, 0.1); // fade the room UP as it recedes
}
Right, the payoff of the whole episode. Let's take a single sound and fly it around the listener in a circle - and because we've wired everything in real 3D, the panning, distance and tone all update for free as it moves. This is where headphones earn their keep: the sound will visibly (audibly!) swing behind your head and back round.
// ORBIT: a sound source flying in a circle around the listener's head.
const orbiter = new Tone.Panner3D({ panningModel: "HRTF" }).toDestination();
const drone = new Tone.Oscillator("E3", "sawtooth").connect(orbiter).start();
let angle = 0;
function moveOrbit() {
angle += 0.02; // advance around the circle each frame
const radius = 4; // 4 units out from the head
orbiter.positionX.value = Math.cos(angle) * radius; // trig from episode 13!
orbiter.positionZ.value = Math.sin(angle) * radius; // x/z = the horizontal plane
// y stays 0 -> it circles at ear height. lift y and it spirals overhead.
requestAnimationFrame(moveOrbit);
}
moveOrbit();
Look at that - Math.cos and Math.sin driving a circle, the exact same trig we used to swing particles around a point back in episode 13. A position is a position whether it's a pixel or a sound source. The engine takes those X/Z numbers and does all the ear maths so the drone genuinly circles you. Close your eyes with headphones on and point at where you think it is - you'll be right, and it's a lovely little "oh!" moment.
And now we close the loop with episode 123. We spent that whole episode locking a visual to a sound in time. Here we lock a visual to a sound in space: one shared position drives both the dot on screen and where the sound is heard. Same coordinate, two senses. When they're truly the same number, the fusion is total - the dot doesn't just represent the sound, it is the sound, wearing pixels.
// ONE position -> the dot on screen AND the sound's place in 3D. one truth, two senses.
let a = 0;
function frame(p) {
a += 0.02;
const x = Math.cos(a), z = Math.sin(a); // the shared position, on the unit circle
// 1) feed it to the EARS (3D pan):
orbiter.positionX.value = x * 4;
orbiter.positionZ.value = z * 4;
// 2) feed the SAME position to the EYES (map the circle onto the canvas):
const screenX = p.width / 2 + x * 200;
const screenY = p.height / 2 + z * 120; // z read as vertical depth on screen
p.background(12);
p.fill(255, 180, 60);
p.circle(screenX, screenY, 24); // the dot lives where the sound lives
}
Mute it and watch the dot circle silently; unmute and it sings from wherever it happens to be. That's the same "grow both from one source" principle from last episode, just with position as the shared seed instead of a noise field. This is the idea I keep coming back to: you're not building a picture and a soundtrack side by side, you're building one system and letting it speak through both channels at once.
I'll gently point at the deep end so you know it's there. Everything above places sounds one at a time. Ambisonics flips it: instead of positioning individual sources, you capture or synthesise a whole spherical sound field - the entire "what arrives from every direction" - as a small set of channels, and then decode it to whatever speaker setup or headphones you have. It's how 360 video and VR audio work, so you can turn your head and the whole world stays put. The Web Audio building blocks are all there to build a decoder, but it's a proper rabbit hole - for now, Panner3D per source will carry you a very long way, and it's the right place to build your intuition first.
// ambisonics in one breath: don't place sources, encode the WHOLE field.
const ambisonicsIdea = {
perSource: "Panner3D - position each sound individually. simple, intuitive. START HERE.",
ambisonic: "encode the ENTIRE surrounding field as channels, decode to any output.",
usedFor: "VR / 360 video - turn your head, the world stays fixed. big rabbit hole.",
};
Let's build something you'll want to keep. The brief: three sound sources at different distances and speeds, each orbiting the listener at its own radius and pace, each drawn as a dot whose size tracks its distance - and all of it on HRTF so it genuinly surrounds you on headphones. A little solar system you can hear.
// SONIC SOLAR SYSTEM: three orbiting voices, each its own radius, speed and pitch.
Tone.getContext().rawContext.listener.positionZ.value = 0; // ears at the origin
function makePlanet(note, radius, speed, wave) {
const pan = new Tone.Panner3D({ panningModel: "HRTF", rolloffFactor: 1.2 }).toDestination();
const osc = new Tone.Oscillator(note, wave).connect(pan).start();
osc.volume.value = -18; // keep them polite together
return { pan, radius, speed, angle: Math.random() * 6.28 }; // random start phase
}
const planets = [
makePlanet("C3", 3, 0.010, "sine"), // near, slow, low
makePlanet("G3", 6, 0.020, "triangle"), // mid
makePlanet("C5", 9, 0.035, "sine"), // far, fast, high
];
Now the loop that moves them and draws them. Each planet's X/Z comes straight from cos/sin of its own angle - and the same numbers place both the sound and the dot. Distance sets the dot's size (near = big, far = small), so the picture agrees with what your ears report.
// move + draw every planet from ONE shared position each. ears and eyes agree.
function draw(p) {
p.background(10, 10, 16);
p.noStroke();
for (const pl of planets) {
pl.angle += pl.speed;
const x = Math.cos(pl.angle) * pl.radius;
const z = Math.sin(pl.angle) * pl.radius;
pl.pan.positionX.value = x; // -> the ears
pl.pan.positionZ.value = z;
const sx = p.width / 2 + x * 22; // -> the eyes (scale the world to the canvas)
const sy = p.height / 2 + z * 22;
const size = p.map(pl.radius, 3, 9, 34, 10); // near planets draw BIGGER
p.fill(255, 200, 120);
p.circle(sx, sy, size);
}
p.fill(120, 160, 255);
p.circle(p.width / 2, p.height / 2, 12); // the listener, at the centre
}
Wire it behind a "click to start" button (never forget Tone.start() - we've been burned by that one enough times now), pop your headphones on, and just sit in it for a minute. Three little worlds circling you at their own speeds, each singing from its own place, drifting behind you and back round the front. It's a genuinly calming thing to have made from cos, sin, and a couple of panner nodes.
Push it when you've got an evening: lift each planet's positionY with a slow sine so they spiral overhead too, add distance-linked reverb so the far one sounds washed in room while the near one stays dry, or - my favourite - let the user drag the listener around with the mouse so they move through the little system instead of it orbiting them. Same handful of parts, endless variations. It's yours now :-).
Tone.Panner, -1 to +1) is the one-dimensional starter - louder in one ear than the other. Automate it for drifts, and give every instrument its own pan spot for an instant "stereo image" that costs one number eachTone.Panner3D is the real jump: a sound with an (x, y, z) position and a listener with a position and facing direction - the exact objects-and-camera model from our Three.js episodes, but for the ears. Use panningModel: "HRTF" and tell people to wear headphonesrolloffFactor, close a lowpass as things recede, and blend more reverb in with rangePanner3D's X/Z with cos/sin (episode 13!) and a source genuinly orbits your head. Feed that same shared position to a dot on screen and you've locked a visual to a sound in space - episode 123's time-sync, now in 3DPanner3D per source is the right place to build intuition firstSo that's space, added to the picture. We started this arc making single sounds, taught the machine to choose notes, gave those notes rhythm, drifted them into ambient beds, locked them to visuals in time last episode, and now we've given them a place - direction, distance, a room, and motion through all three. Your sounds have a what, a when, and now a where. That's a properly complete little world to be standing in.
And it makes me itch to break sound down even further. All episode we've been moving whole sounds around a room - a full note, a complete drone, picked up and placed. But what if the raw material itself got smaller? What if instead of playing a sound you chopped it into thousands of tiny grains and rebuilt it - stretched, frozen, smeared, reassembled into textures that the original never contained? There's a way of thinking about sound as particles rather than notes, and it's exactly as wild as it sounds. Build your sonic solar system first - genuinly sit with headphones on while three little worlds circle your head, it's worth it - and then come back, because next time we take sound apart grain by grain :-).
Sallukes! Thanks for reading.
X