Last episode we built a drum machine - grids, steps, swing, polyrhythm, the whole punchy toolbox - and I left you with a little cliffhanger: some of the most beautiful generative music does the exact opposite of all that. No beat. No grid. No hits marching in a line. Just slow, evolving washes of sound that never quite repeat, the kind of thing you could leave running for an hour and forget was even playing. Today we let the grid dissolve. Today we make ambient. Allez, this is my favourite corner of the whole audio world, so pour something warm and settle in :-).
Here's the mindset shift, and it's a big one. Everything we've done with sound so far has been about events - trigger a note, hit a drum, place a thing at a moment in time. Ambient music isn't really about events at all. It's about states that slowly change. You're not composing a sequence, you're tending a little ecosystem of sound and nudging it now and then. Brian Eno, the guy who basically invented this whole approach, defined ambient music as something that has to be "as ignorable as it is interesting". I love that line so much. It's music that rewards you if you listen closely but never demands it. And it turns out that "quietly interesting forever" is an almost perfect target for generative systems, because the one thing computers are brilliant at is doing slightly-different-forever without getting bored. Let me show you the pieces.
The absolute heart of ambient is the drone - a sustained tone that just... sits there and glows. But a single oscillator holding one pitch is dead boring, it's a test tone, it sounds like a hearing exam. The trick that brings it alive is almost embarrassingly simple: play two oscillators at almost the same pitch. Not identical - a tiny bit apart. Say 220Hz and 221Hz.
// two oscillators, ALMOST the same pitch. the 1Hz gap makes the sound "breathe".
const oscA = new Tone.Oscillator(220, "sine").toDestination();
const oscB = new Tone.Oscillator(221, "sine").toDestination();
oscA.volume.value = -12; // keep drones quiet - they're a bed, not a lead
oscB.volume.value = -12;
oscA.start();
oscB.start();
Why does that sound so much richer than one? Because two waves that are close but not equal drift in and out of phase with each other, and when they line up they add, when they oppose they cancel. The result is a slow pulsing in the volume - a beating - at exactly the difference frequency, here 1Hz, so one gentle swell per second. This is the same maths as the polyrhythm from last episode, honestly, just moved down into the audio itself: two periods with a large least-common-multiple, sliding against each other. Detune a little and the sound shimmers; detune a lot and it gets rough and buzzy. That controlled shimmer is the soul of a good pad.
And here's where it gets properly generative. What if the detuning itself changes over minutes? You set the second oscillator's frequency to drift slowly, and the beating speeds up and slows down forever, so the drone never settles into a fixed pulse.
// slowly drift the detune so the "breathing" rate itself evolves over minutes.
// rampTo eases a value to a target over a given time (in seconds).
function driftDetune(osc, base) {
const target = base + (Math.random() * 4 - 2); // wander +/- 2Hz from base
osc.frequency.rampTo(target, 20); // ease there over 20 seconds
// schedule the next drift once this one lands
setTimeout(() => driftDetune(osc, base), 20000);
}
driftDetune(oscB, 221); // oscB now wanders gently around 221Hz forever
See what we did there? We didn't schedule a melody. We set up a tendency and let it run. That's the ambient way of thinking in a nutshell - describe a behaviour, not a score.
In normal music reverb is a bit of polish you add at the end to place things in a room. In ambient it does something much bigger: it's practically the main instrument. Give a short little "plink" a reverb tail of ten, twenty, thirty seconds and the plink stops being a note and becomes a slowly blooming cloud. The reverb does the work of sustaining the sound, so you can feed it tiny sparse events and get an enormous continuous wash back out.
// a MASSIVE reverb - the tail does the sustaining. short notes become long clouds.
const bigReverb = new Tone.Reverb({
decay: 15, // 15-second tail! notes bloom and blur into each other
preDelay: 0.1,
wet: 0.85, // mostly wet - we want the cloud, not the dry plink
}).toDestination();
// a soft bell to feed into it (FM from episode 119 - fractional harmonicity = bell)
const bell = new Tone.FMSynth({
harmonicity: 2.4,
modulationIndex: 6,
envelope: { attack: 0.01, decay: 1.5, sustain: 0, release: 2 },
}).connect(bigReverb);
Reverb in Tone.js needs a moment to render its impulse response before you use it, so you await .generate() or .ready - don't forget that or the first few seconds come out dry. Now watch what a single note does through a tail like this:
// ONE short note, but the 15s reverb turns it into a 15s evolving texture.
await bigReverb.generate(); // let the reverb build its tail first
bell.triggerAttackRelease("C5", "8n"); // a tiny plink -> an enormous bloom
You play one note and the room keeps singing it back to you for a quarter of a minute, blurred and soft. Feed it a handful of these over a couple of minutes and they overlap into a continuous, glowing bed. This is the single biggest "aha" of ambient production - the space between notes isn't empty, it's full of the reverb of the last one.
So how do we feed that reverb? Not with a rhythm - we're done with grids. Instead, every voice gets a probability of playing a note every so often. Low probability - say 15% every couple of seconds - means notes come sparse, unpredictable, and spacious, like the occassional drop off a leaf after rain. This is exactly Eno's approach, and it's beautifully little code.
// each "tick", roll a die. LOW probability = sparse, spacious, unpredictable notes.
// this is the core of generative ambient - chance, not sequence.
const notePool = ["C4", "E4", "G4", "B4", "D5"]; // a nice open chord to draw from
const rainVoice = new Tone.Loop((time) => {
if (Math.random() < 0.15) { // only ~15% of ticks fire
const note = notePool[Math.floor(Math.random() * notePool.length)];
bell.triggerAttackRelease(note, "8n", time);
}
}, "2n").start(0); // "check" every half-note, but rarely act
Tone.Transport.start();
Because the probability is low and the reverb tail is huge, you get this lovely effect where notes arrive when they feel like it, hang in the air, and gently pile up. Turn the probability up and it rains harder; turn it down and it becomes almost silence with the occasional distant chime. That one number is a density dial, and we'll come back to it in a big way at the end.
Now, which notes go in that pool matters enormously, because in ambient there's nowhere to hide - everything overlaps everything thanks to the reverb, so a single wrong note hangs around for fifteen seconds reminding you of your mistake. The safest, richest trick is to draw notes from a harmonic series: pick a low fundamental and only use its integer multiples (1f, 2f, 3f, 4f...). These are the frequencies that are naturally already inside the fundamental (remember Fourier from episode 119), so they always sound consonant, always sound like they belong together.
// harmonic series: multiples of one fundamental. always consonant, always "belongs".
function harmonicSeries(fundamental, count) {
const freqs = [];
for (let n = 1; n <= count; n++) {
freqs.push(fundamental * n); // 1f, 2f, 3f, 4f, ... pure overtones
}
return freqs;
}
const pool = harmonicSeries(110, 8); // 110, 220, 330, 440... rich and consonant
// [110, 220, 330, 440, 550, 660, 770, 880]
And here's the slow-motion version of a key change: every few minutes, quietly shift the fundamental. Everything re-tunes around the new root and the whole piece drifts into a different "colour" without any dramatic transition. Nobody notices the moment it happens, they just feel that things are somewhere new now.
// slow harmonic modulation: every few minutes, pick a new fundamental.
// the piece drifts to a new key so gently nobody hears the "change".
let currentPool = harmonicSeries(110, 8);
function shiftKey() {
const roots = [98, 110, 123, 131]; // G2, A2, B2, C3 - gentle neighbours
const newRoot = roots[Math.floor(Math.random() * roots.length)];
currentPool = harmonicSeries(newRoot, 8);
setTimeout(shiftKey, 120000); // wander to a new root every 2 minutes
}
shiftKey();
Consonance you can't really break, evolving forever. That's the whole game.
One voice raining consonant notes into a big reverb is already lovely, but the depth in real ambient comes from layers - four, five, six independent voices, each with its own pitch range, its own envelope, its own timing, and crucially each fading in and out on its own slow schedule. At any moment some voices are present and some are resting, so the texture is constantly shifting even though nothing ever jumps.
// a "voice" = a synth + its own slow fade in/out lifecycle. many of these = depth.
function makeVoice(synth, reverb, probability, register) {
synth.connect(reverb);
const vca = synth.volume;
vca.value = -Infinity; // start silent
// slowly breathe this voice in and out over long stretches
function breathe() {
vca.rampTo(-14, 25); // fade IN over 25s
setTimeout(() => vca.rampTo(-Infinity, 30), 45000); // then OUT over 30s
setTimeout(breathe, 90000); // whole cycle ~90s, then again
}
breathe();
return new Tone.Loop((time) => {
if (Math.random() < probability) {
const f = register[Math.floor(Math.random() * register.length)];
synth.triggerAttackRelease(f, "2n", time);
}
}, "2n").start(0);
}
The important detail is that each voice's breathe cycle has a different period - 90 seconds here, but make another 70, another 110 - so they never all swell or all rest at the same time. It's the exact same principle as the layered fade-ins we've been chasing since the particle systems, and the same principle as those out-of-sync detuned oscillators: independent slow cycles with mismatched periods produce something that feels alive and organic, because nothing in nature ticks in unison either. Makes sense, right?
We hear in stereo (and installations often have many speakers), and ambient loves space. If every voice sits dead-centre they smear into a mono blob, but pan each one to its own spot and suddenly the sound has width and depth - you can almost point at where each layer is coming from. Slowly moving that pan position adds a gentle drifting motion, sound wandering around your head.
// give each voice its own position, and let it slowly drift across the stereo field.
function spatialVoice(synth, reverb) {
const panner = new Tone.Panner(0).connect(reverb);
synth.connect(panner);
// an LFO slowly sweeps the pan from left to right and back, over ~40s
const drift = new Tone.LFO({ frequency: 1 / 40, min: -0.8, max: 0.8 });
drift.connect(panner.pan);
drift.start();
return panner;
}
Give each voice a different LFO rate and phase and the whole soundfield becomes this slow, three-dimensional swirl. Close your eyes with decent headphones and you'll feel the layers moving past each other. It costs almost nothing and it's the difference between "flat" and "immersive".
One more technique, and it's a bit magic. Granular synthesis chops a sound into tiny fragments - "grains" of maybe 10 to 100 milliseconds - and scatters them: overlapping, repitching, spreading them in time. Do this to any sample and it dissolves into a shimmering ambient texture. A one-second recording of a piano note becomes an endless glassy cloud. Birdsong becomes a soft wash. It's like time-stretching a sound so far that individual events melt into pure texture.
// GrainPlayer chops a sample into tiny grains and scatters them -> ambient cloud.
// any short sample becomes an endless evolving texture.
const grains = new Tone.GrainPlayer({
url: "piano-note.wav",
grainSize: 0.08, // 80ms fragments
overlap: 0.6, // grains overlap heavily -> smooth, continuous
playbackRate: 0.5, // half speed -> stretched, dreamy
loop: true,
detune: 0,
}).connect(bigReverb);
grains.volume.value = -18;
Tone.loaded().then(() => grains.start()); // wait for the sample to load first
Wobble the detune and playbackRate slowly over time and the cloud keeps shifting colour. Feed it through the same giant reverb and the grains and the tail become almost indistinguishable - which is the point. You've taken one little sound and turned it into weather.
Here's a mistake I made for ages: I thought ambient meant "no structure, just let it run flat forever". Nope. The best ambient still has a large-scale shape, just a very slow one - a sparse opening, a gradual thickening, a plateau, a slow thinning back to near-silence. A thirty-minute arc. And the gorgeous thing is you can drive that entire arc off a single number: a density value that rises and falls, feeding straight into all those note probabilities from earlier.
// one "density" curve drives the whole piece's arc: sparse -> full -> sparse.
// map elapsed time to a slow rise-and-fall, and feed it to every voice's probability.
let startTime = Tone.now();
function currentDensity() {
const elapsed = Tone.now() - startTime; // seconds since start
const period = 30 * 60; // a 30-minute arc
const phase = (elapsed % period) / period; // 0..1 around the arc
// a smooth hump: 0 at the ends, 1 in the middle (sine eased)
return Math.sin(phase * Math.PI) * 0.4; // peaks at 40% probability
}
Then instead of hard-coding 0.15 in each voice, you ask the density curve every time. The music thickens toward the middle of the arc and thins toward the edges, all on its own, forever, and no two half-hours land the same because the note choices underneath are still random.
// each voice reads the shared density curve instead of a fixed probability.
const arcVoice = new Tone.Loop((time) => {
if (Math.random() < currentDensity()) { // probability breathes over 30 min
const f = currentPool[Math.floor(Math.random() * currentPool.length)];
bell.triggerAttackRelease(f, "2n", time);
}
}, "2n").start(0);
I can't write an Eno-flavoured episode without this one. Alongside ambient, Eno made a deck of cards called Oblique Strategies - little cryptic creative prompts you draw when you're stuck. "Use fewer notes." "Repetition is a form of change." "Honour thy error as a hidden intention." They're gorgeous, and they map perfectly onto generative art: they're just random constraints your generator can impose on itself.
// Oblique Strategies as generative constraints the piece imposes on itself.
const strategies = [
() => { /* "use fewer notes" */ currentPool = currentPool.slice(0, 3); },
() => { /* "repetition is change" */ notePool.push(notePool[0]); },
() => { /* "honour thy error" */ bigReverb.wet.rampTo(0.95, 10); },
() => { /* "make it quieter" */ bell.volume.rampTo(-20, 15); },
];
// every few minutes, the piece "draws a card" and reshapes its own rules
function drawCard() {
strategies[Math.floor(Math.random() * strategies.length)]();
setTimeout(drawCard, 180000); // a new strategy every 3 minutes
}
drawCard();
That's a system that quietly rewrites its own behaviour as it plays. It's a tiny thing but philosophically it's the whole spirit of generative art - you're not the composer any more, you're the person who set up the conditions for surprise.
Right, let's put the whole toolbox into one thing you can genuinly leave playing while you work. The brief: four sine voices with long soft envelopes, notes drawn from a harmonic series, chance-based triggering driven by a slow density arc, each voice panned to its own drifting spot, everything soaked in a fifteen-second reverb. No two minutes should ever sound the same, but it should always sound coherent, because the harmonic series makes ugly nearly impossible.
// AMBIENT GENERATOR: 4 voices, harmonic pool, density arc, big reverb, spatial drift.
const verb = new Tone.Reverb({ decay: 18, wet: 0.9 }).toDestination();
let root = 110, harmonics = harmonicSeries(root, 8), t0 = Tone.now();
function density() { // slow 20-min swell/fade
const p = ((Tone.now() - t0) % 1200) / 1200;
return Math.sin(p * Math.PI) * 0.35;
}
Build four voices, each a soft sine with a long attack and release so notes fade in rather than pluck, each on its own panner with its own slow LFO, each reading the shared density curve.
// one factory builds all four voices, each spatially placed and slowly drifting.
function ambientVoice(i) {
const synth = new Tone.Synth({
oscillator: { type: "sine" },
envelope: { attack: 2, decay: 1, sustain: 0.4, release: 6 }, // slow, soft
});
const pan = new Tone.Panner(0).connect(verb);
synth.connect(pan);
synth.volume.value = -16;
const lfo = new Tone.LFO({ frequency: 1 / (30 + i * 7), min: -0.7, max: 0.7 });
lfo.connect(pan.pan); lfo.start(); // each voice drifts at its own rate
return new Tone.Loop((time) => {
if (Math.random() < density()) {
const f = harmonics[Math.floor(Math.random() * harmonics.length)];
synth.triggerAttackRelease(f, "4n", time);
}
}, "2n");
}
Wire them up, add the slow key-drift so the piece wanders harmonically, and start everything behind a "click to start" button (never forget Tone.start() - we learned that the hard way a few episodes back).
// assemble the piece and let it run. remember Tone.start() needs a user click!
async function startAmbient() {
await Tone.start();
await verb.generate(); // render the long reverb tail first
for (let i = 0; i < 4; i++) ambientVoice(i).start(0);
// slow key drift every 2 minutes so the whole thing wanders
setInterval(() => { root = [98, 110, 123, 131][Math.floor(Math.random() * 4)];
harmonics = harmonicSeries(root, 8); }, 120000);
Tone.Transport.start();
}
document.querySelector("#start").addEventListener("click", startAmbient);
Hit start and then just... leave it. Go make tea. Come back in ten minutes and it'll be somewhere completely different, but it'll still sound like the same piece - same voice, same mood, endlessly reshuffled. That's the whole magic of the thing, and I promise the first time you leave one of these running for an hour and realise you never got tired of it, you'll be hooked like I am.
Push it when you've got an evening: add a granular voice from a field recording you made on your phone, give each voice a gentle lowpass that opens and closes on its own LFO, or draw a slow visual to go with it - a fog of soft particles whose opacity tracks the density curve, so the picture thins and thickens with the sound :-).
So that's ambient - the patient, generous, endlessly-patient side of generative sound. You can build a drone that breathes, feed a giant reverb with rainfall notes, keep it all consonant with a harmonic series, and give the whole thing a half-hour shape from a single curve. It's the first music we've made that has no beginning and no end, that you set going rather than play, and honestly it's the closest sound gets to the way we've always thought about generative visuals - describe a system, press go, watch it live.
And that word "watch" is nagging at me, because we've now built two whole worlds - a visual one and a sonic one - and we've mostly kept them in separate rooms. We've flashed a screen on a kick and tracked a density curve with opacity, little handshakes here and there. But what would it take to lock them together properly, so that what you see and what you hear are truly the same thing moving, sample-accurate, one system with two senses? That's the thread I want to pull next, and it's where this whole audio stretch has quietly been heading all along. Build your ambient generator first - genuinly leave it running for a real hour, it changes how you hear the idea - and then come back, because next time the eyes and the ears finally meet :-).
Sallukes! Thanks for reading.
X