Gain, mixing, and envelopes
A GainNode does exactly one thing — multiply every incoming sample by a number — and from that single operation fall three distinct uses that look unrelated until you see the mechanism underneath — plain volume control, mixing multiple sources by simple addition, and the amplitude envelopes that give a note its character.
Gain, mixing, and envelopes
Of every node in the Web Audio graph, GainNode is the one you'll instantiate the most, and it does the least: for every sample that arrives at its input, it multiplies that sample by its gain value and passes the result along. That's the entire operation — one multiplication, 128 times per render quantum, forever. It's tempting to read that as "the boring node," but three of the most important things you do in Web Audio are just this one multiplication used in different roles: turning volume up and down, summing several sources into a mix, and shaping how a note's loudness moves over its lifetime. This lesson takes the multiplication apart into all three.
Gain is multiplication, and multiplication is volume
gain.gain is an AudioParam (the scheduling lesson covers its full timeline vocabulary), and its .value is the multiplier applied to every sample passing through:
const context = new AudioContext();
const osc = context.createOscillator();
const gain = context.createGain();
osc.connect(gain);
gain.connect(context.destination);
gain.gain.value = 0.5; // every sample leaving osc is halved on its way through
osc.start();A gain of 1.0 passes the signal through unchanged, 0.5 halves every sample's amplitude, 0.0 is silence (every sample multiplied by zero is zero), and anything above 1.0 amplifies — makes the signal louder, at the risk covered below. There's no separate "volume" concept in Web Audio distinct from this; turning something up or down is scaling its samples, and a GainNode is the node that does the scaling.
Mixing: connecting many sources to one node sums them
Multiplication explains volume. Addition explains mixing, and it comes from a mechanical fact about .connect() that hasn't come up yet: when more than one node connects to the same destination, their sample streams aren't switched between or layered visually — they are summed, sample by sample, at the receiving node's input. A "mixer" in Web Audio isn't a special node; it's just what happens automatically when multiple sources fan into one:
const context = new AudioContext();
const oscA = context.createOscillator();
const oscB = context.createOscillator();
oscA.frequency.value = 440; // A4
oscB.frequency.value = 554; // near a major third above
const gainA = context.createGain(); // per-voice level for oscA
const gainB = context.createGain(); // per-voice level for oscB
const master = context.createGain(); // one shared level for the whole mix
oscA.connect(gainA);
oscB.connect(gainB);
gainA.connect(master); // both gainA and gainB connect to master...
gainB.connect(master); // ...so master's input is their sum, not either one alone
master.connect(context.destination);
master.gain.value = 0.7; // headroom, see below
oscA.start();
oscB.start();This is also what a .connect() graph in general does in both directions. Fan a single source out to several nodes (one oscillator connected to both a dry path and a delay-effect path, say) and each destination gets its own full, undiminished copy of the signal — fan-out duplicates. Fan several sources in to one node, as above, and that node's input is their sum — fan-in adds. Both are just .connect() called more than once, on the source side or the destination side; the node itself has no idea whether it's part of a fan-out or a fan-in, it just processes whatever ends up at its input.
Clipping: what happens when the sum gets too big
Summation has a consequence worth naming explicitly. Web Audio's internal samples are 32-bit floats nominally meant to sit in roughly -1.0 to +1.0 (the samples lesson covers this range). Adding two signals that are each individually well within that range can easily push their sum outside it — two sources peaking near 0.8 at the same instant sum to 1.6, well past 1.0. When that combined signal eventually reaches the audio output hardware, values outside the representable range get clipped — hard-limited to the boundary — which produces a harsh, distorted, crackling sound, not a graceful "extra loud."
The fix is the master gain node already sitting in the example above: route every mixed source through one shared gain, set below 1.0, so the summed signal has headroom before it ever reaches the destination. This is the master-bus pattern — sources feed per-voice gains for individual level control, all of those feed one shared master gain for overall headroom and a single volume knob for the whole mix, and only the master connects to context.destination.
Envelopes: shaping loudness over the life of a note
Volume and mixing both treat gain as roughly a single number you set once. An envelope treats gain as a value that moves over time, following the same automation timeline from the scheduling lesson — because a real note practically never jumps instantly to full volume and cuts off instantly either. A plucked string leaps up fast and rings out slowly; a bowed pad swells in gradually and fades out gradually. Both are the same GainNode, driven by a different shape of automation.
The classic shape for this is ADSR — four stages, all built from the ramp methods you already have:
- Attack — from silence up to peak volume, right when the note starts. A fast attack (a few milliseconds) sounds percussive and plucked; a slow attack (hundreds of milliseconds) sounds like a swell.
- Decay — from that peak down to a lower sustain level, immediately after the attack finishes.
- Sustain — not a ramp at all, just a level held steady for as long as the note is considered "held down."
- Release — from the sustain level down to silence, once the note is let go.
function playNote(context, frequency, { attack = 0.02, decay = 0.15, sustainLevel = 0.4, } = {}) {
const osc = context.createOscillator();
const envelope = context.createGain();
osc.frequency.value = frequency;
osc.connect(envelope);
envelope.connect(context.destination);
const t = context.currentTime;
envelope.gain.setValueAtTime(0, t); // start silent
envelope.gain.linearRampToValueAtTime(1.0, t + attack); // attack: up to peak
envelope.gain.linearRampToValueAtTime(sustainLevel, t + attack + decay); // decay: down to sustain
osc.start(t);
return { osc, envelope }; // held open at sustainLevel until noteOff releases it
}
function releaseNote({ osc, envelope }, context, release = 0.3) {
const t = context.currentTime;
envelope.gain.cancelScheduledValues(t); // stop any pending ramp first
envelope.gain.setValueAtTime(envelope.gain.value, t); // pin the current value
envelope.gain.setTargetAtTime(0, t, release / 3); // release: asymptotic fade to ~0
osc.stop(t + release + 0.05); // let the release finish before the source dies
}The exact same oscillator, wired through the exact same gain node, sounds completely different depending only on this envelope's shape: a near-instant attack with a short decay and a fast release sounds like a plucked string or a drum hit, while a slow attack, a gentle decay, and a long release on the identical oscillator sounds like a synth pad swelling in and fading out. None of that character comes from the oscillator's waveform — it comes entirely from how its gain is automated over time. That's the payoff of treating gain as a schedulable timeline rather than a single number: the envelope is the instrument's personality, layered on top of whatever raw tone the source produces.
Note that releaseNote uses setTargetAtTime rather than a linear ramp for the release — an asymptotic decay is the natural shape for a sound trailing off (exactly as the scheduling lesson describes for a physical system losing energy), and it also avoids ever demanding an exact "reach zero at this instant," which matters because the source keeps producing samples until stop() actually takes effect.
The master-bus pattern, stated in full
Put the two ideas of this lesson together and you get the standard shape almost every real Web Audio instrument or mixer uses:
Each voice gets its own gain node so its envelope and level are independent of every other voice; all of them sum into one master gain so there's a single place to manage overall headroom and a single fader for the whole mix. Every polyphonic instrument, every game's sound mixer, and every DAW's channel strip is a variation on exactly this diagram — sources into per-voice gains, summed into a master gain, out to the destination.
Where this goes next
Gain reshapes amplitude; it says nothing about frequency content — a GainNode treats a bright sawtooth and a soft sine identically, scaling both the same way. Filters and effects picks up where amplitude leaves off, covering the nodes that shape a signal's frequency content instead: cutting highs, boosting lows, carving out a resonant peak.
Go deeper
- MDN — GainNode — The gain AudioParam and its default/range, confirming the plain multiplication semantics this lesson builds everything on.
- MDN — audio graphs, fan-in and fan-out — Confirms the summing behavior of multiple connections into one node, the mechanism this lesson calls mixing.
- W3C spec — setTargetAtTime — The precise asymptotic formula behind the release stage used in this lesson's ADSR example.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- In one sentence, what operation does a GainNode perform on every sample that passes through it, and how does that operation produce 'volume control'?
- Three oscillators all connect to the same GainNode. What happens to their three sample streams at that node's input, mechanically?
- Why can a mix clip even when every individual source stays within -1.0 to +1.0, and what's the standard fix?
- What's the difference between fan-out and fan-in, and which one is responsible for the clipping risk described in this lesson?
- Name the four stages of an ADSR envelope and, for each, say whether it's a ramp or a held value.
- Why does the release stage in the code example use setTargetAtTime instead of linearRampToValueAtTime?
- Describe the master-bus pattern: what connects to what, and why does each voice get its own gain node instead of sharing one?