Under the Hood
Webaudio

Source nodes: oscillators, buffers, and the microphone

Sources are where signal enters the graph at all — this lesson covers synthesizing a waveform with an OscillatorNode, playing recorded samples with an AudioBufferSourceNode, and the one-shot lifecycle that explains why you build a brand-new source node every single time you play a sound.

Source nodes: oscillators, buffers, and the microphone

Every graph in this module so far has started mid-sentence: "connect the oscillator to the gain" assumes an oscillator already exists, producing a signal from somewhere. This lesson is about that somewhere — the small family of nodes whose whole job is to put a signal into the graph in the first place, either by synthesizing one from a mathematical description or by reading one back from data. Everything downstream — filters, gains, effects — has nothing to process until a source hands it samples.

There are two ways a source can produce those samples: compute them from a waveform description, or read them from an already-sampled buffer. An OscillatorNode does the first. An AudioBufferSourceNode does the second, pulling from the AudioBuffer the previous lesson took apart. Two more nodes tap signal from outside the graph entirely — a media element or a microphone. All four share one mechanical rule that this lesson spends real time on: a source plays exactly once, and then it is finished for good.

OscillatorNode: computing a waveform, sample by sample

An OscillatorNode doesn't play anything back — it generates a periodic waveform from a formula, fresh, every render quantum. You pick a shape with type and a pitch with frequency, and the render thread computes 128 new points along that shape every ~2.9 ms, forever, until you stop it.

const context = new AudioContext();
const osc = context.createOscillator();

osc.type = "sine";        // pick the waveform shape
osc.frequency.value = 440; // A4, in Hz

osc.connect(context.destination);
osc.start();                // begin producing samples now
osc.stop(context.currentTime + 1); // schedule the end, 1 second out

type has four built-in shapes, and the difference between them is not "louder" or "different pitch" — all four at frequency = 440 complete 440 cycles per second. The difference is timbre, and it comes down to harmonic content:

  • sine — a single pure frequency and nothing else. No harmonics at all, which is why it sounds smooth, hollow, almost like a tuning fork.
  • square, sawtooth, triangle — each is mathematically a sum of the fundamental frequency plus a whole staircase of harmonics (integer multiples of the fundamental) at various strengths. More energy in the higher harmonics reads as a brighter, buzzier, more "electric" timbre — a sawtooth (richest in harmonics) sounds harsh and bright, a triangle (harmonics fall off fast) sounds softer and closer to a sine, and square sits in between with a distinctive hollow, reedy character from its odd-harmonics-only makeup.
  • A custom PeriodicWave (built with context.createPeriodicWave() from your own harmonic amplitudes, assigned via osc.setPeriodicWave()) lets you specify exactly which harmonics are present and how strong each one is — the general case the four built-in shapes are just convenient presets of.

frequency and detune (a pitch offset measured in cents, 100 cents to a semitone) are not plain numbers — they're AudioParams, exactly like the gain you've already scheduled. That means a pitch bend, a vibrato, or a frequency sweep is built with setValueAtTime/linearRampToValueAtTime against the audio clock, not by reassigning .frequency.value in a loop — the full toolkit from the scheduling lesson applies here without modification.

Turn the knobs yourself: change the waveform and pitch below and watch the shape update on an oscilloscope while it plays.

makes sound — unmute your device
Waveform
Frequency — 220 Hz
Volume — 0.12

This is a live audio graph: an OscillatorNode generates a waveform on the audio thread, flows through a GainNode that sets its volume, then an AnalyserNode that taps the signal without altering it, and finally to your speakers. The oscilloscope above reads that analyser once per animation frame on the main thread — a separate, slower loop peeking at audio that is actually being generated far faster, sample by sample, on the audio thread. Changing the waveform changes its harmonic content: a sine is a pure tone, while square and sawtooth waves add overtones that sound "buzzier".

The one-shot lifecycle: play once, then dead

Here is the detail the flagship lesson flagged and deferred: start(when) and stop(when) are not pause/resume controls. start schedules the moment the node begins producing samples, on the audio clock (context.currentTime), and stop schedules the moment it permanently ends. Once a source has stopped — whether you called stop() or an AudioBufferSourceNode simply ran out of buffer — that node is finished. Calling start() on it again throws an InvalidStateError. There is no resume(), no restart(), no rewinding.

That isn't an oversight; it's the deliberate design this module keeps returning to. Source nodes are meant to be cheap, disposable generators — creating a new OscillatorNode or AudioBufferSourceNode costs almost nothing, so the API leans into that and makes each one single-use. The alternative — a reusable, restartable source — would need to track playback position, handle being restarted mid-graph-reconfiguration, and generally carry state the render thread would have to guard on every sample. Making sources one-shot keeps their internal state trivial (running, or stopped-forever) and keeps scheduling unambiguous: a given node's start/stop pair fully describes its entire lifetime, with no way for some other piece of code to have quietly paused or rewound it in between. The practical consequence: every time you want to play a sound again, you build a fresh node — new createOscillator(), new createBufferSource() — connect it, and start it. The AudioBuffer or waveform parameters can be reused; the source node wrapping them cannot.

AudioBufferSourceNode: playing back an AudioBuffer

Where an oscillator computes a signal, an AudioBufferSourceNode reads one back — it plays the PCM samples inside an AudioBuffer (the object the previous lesson pulled apart down to its Float32Arrays) into the graph.

async function playSample(context, audioBuffer) {
  const source = context.createBufferSource(); // fresh node, every play
  source.buffer = audioBuffer;                 // the decoded PCM to read from
  source.loop = false;                         // true = repeat the whole buffer

  source.connect(context.destination);
  source.start(); // begins reading buffer[0] forward
}

Two more properties shape playback:

  • loop — when true, instead of stopping when it reaches the end of the buffer, the node wraps back to the start (or to loopStart/loopEnd, if set) and keeps reading. Useful for a sustained pad or an ambient bed; still governed by the same one-shot rule underneath — looping doesn't make the node restartable after an explicit stop(), it just means "don't stop on your own at the end."
  • playbackRate and detune — both change how fast the buffer is read, which is why speed and pitch move together here: reading the sample array faster produces both a shorter duration and a higher pitch, exactly like speeding up a tape or a vinyl record, because the node is resampling on the fly — computing output samples at new time positions in the source data rather than one-for-one. A playbackRate of 2.0 plays the buffer in half the time, one octave higher; there's no way to change one without the other on this node alone (a pitch-shifter that preserves duration needs separate processing, not just this parameter).

An AudioBufferSourceNode is one-shot in exactly the sense above: once it stops (naturally, at the end of a non-looping buffer, or via stop()), it's done. Play the same clip again — the buffer stays put in memory, you just wrap it in a new source node.

Tapping signal from outside the graph

Two more source nodes exist not to generate or play back a buffer, but to pull a signal in from something the graph doesn't own:

  • MediaElementAudioSourceNode — created with context.createMediaElementSource(audioOrVideoElement), this routes the output of an existing <audio> or <video> element into the graph, so you can apply gain, filters, or analysis to media the browser is already streaming and decoding. This is the right tool for long files or live streams, where decoding the whole thing up front into an AudioBuffer (as decodeAudioData does) would be wasteful or impossible — the element handles network buffering and progressive playback, and Web Audio just taps the result.
  • MediaStreamAudioSourceNode — created with context.createMediaStreamSource(stream) from a MediaStream (typically from navigator.mediaDevices.getUserMedia({ audio: true })), this routes a live input — the microphone, or another WebRTC peer's incoming audio — into the graph as a continuous source.

Neither of these is one-shot the way an oscillator or buffer source is — there's no start()/stop() pair to exhaust, because the underlying element or stream, not this wrapper node, owns the notion of playing or pausing. The node is just a tap into an external signal for as long as that signal exists.

The autoplay policy: why nothing plays until a click

One more mechanical fact governs every source in this lesson: a freshly constructed AudioContext starts in the suspended state, and a suspended context produces silence no matter how many oscillators you start or buffers you play — the render thread simply isn't running. Browsers enforce this deliberately, as an anti-annoyance measure against pages that blast audio the instant they load. The context only leaves suspended when context.resume() is called from inside a user-gesture handler — a click, a keypress, a tap — a policy the browser checks by looking at whether the call is happening synchronously inside that gesture's event handler.

const context = new AudioContext(); // starts "suspended"

playButton.addEventListener("click", async () => {
  await context.resume(); // must be called inside the gesture handler
  const osc = context.createOscillator();
  osc.connect(context.destination);
  osc.start();
});

This is the mechanical reason every Web Audio demo you've ever seen has a "Play" button instead of making sound on page load — it isn't a design choice by the demo's author, it's a browser requirement with no workaround. The <OscillatorPlayground /> above resumes its context inside its own play button's click handler for exactly this reason.

Where this goes next

Every source above hands its signal to frequency, detune, playbackRate, or some other parameter that behaves like a plain number but isn't — each is an AudioParam with its own schedulable timeline. AudioParam: sample-accurate scheduling against the audio clock is where that timeline mechanism is built out in full, and it's worth reading right after this lesson if you haven't already, because every parameter named here — frequency, detune, playbackRate — is scheduled with exactly the same vocabulary as gain.gain.

Go deeper

  • MDN — OscillatorNode Full reference for type, frequency, detune, and setPeriodicWave, including the exact harmonic makeup of each built-in waveform.
  • MDN — AudioBufferSourceNode The buffer, loop, loopStart/loopEnd, playbackRate, and detune properties this lesson covers, plus the exact one-shot start/stop semantics.
  • MDN — Web Audio best practices: autoplay policy The browser-enforced suspended-until-gesture behavior this lesson explains, with the resume()-in-a-click-handler pattern used here.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What is the mechanical difference between how an OscillatorNode and an AudioBufferSourceNode each produce their samples?
  2. Two oscillators both have frequency = 440, one type sine and one type sawtooth. What's actually different between the sound they produce, in terms of harmonics?
  3. Why are frequency and detune AudioParams instead of plain properties, and what does that let you do that a plain property couldn't?
  4. A source node has just called stop(). What happens if you call start() on it again, and why does the API forbid restarting rather than allowing it?
  5. Why does changing playbackRate on an AudioBufferSourceNode change both speed and pitch together, mechanically?
  6. When would you reach for a MediaElementAudioSourceNode instead of decoding a file into an AudioBuffer and using AudioBufferSourceNode?
  7. A page calls oscillator.start() but nothing plays. List the two independent things worth checking, and why each could cause silence on its own.