Skip to main content
Lightning Jar - Web Studio Lightning Jar Wordmark

A Multitrack Player in 291 Lines of Browser

A watercolor illustration of a baby grand piano

The first commit of Stem Shovel contains a file called engine.svelte.ts. It is 291 lines long, it imports nothing but its own types and a 27-line peak calculator, and it is a complete synced multitrack player: load a song's stems, play them in sample-accurate sync, seek anywhere, mute and solo, ride a fader per stem and a master on top. No audio library, no dependencies, no clever tricks. We have written before about how far you can get by taking what the platform hands you; this is the same lesson with sound coming out.

The Whole Trick is One Clock

A multitrack player sounds like a synchronization problem, and synchronization problems are where projects go to grow beards. The Web Audio API dissolves it. Every stem is decoded up front into an AudioBuffer, each buffer plays through its own GainNode, every gain feeds one master gain, and the master feeds the destination. One AudioContext, one clock. To play, you schedule every source against the same timestamp on that shared clock:

const when = ctx.currentTime + START_LEAD;
for (const stem of this.stems) {
	const source = ctx.createBufferSource();
	source.buffer = this.buffers.get(stem.id);
	source.connect(this.gains.get(stem.id));
	source.start(when, this.offset);
}

That is the whole sync strategy. There is no drift correction because there is nothing to drift: every stem is a sample stream pulled by the same hardware clock from the same deadline. The START_LEAD constant (50 milliseconds) just makes sure every start() call lands before the deadline does. We did not invent sample-accurate playback in a weekend. The browser shipped it, and we scheduled against it.

Everything Else is Bookkeeping

The rest of the file is short because each remaining problem has a small, honest answer.

Seeking is not a seek at all. Buffer sources are one-shot by design, so the engine stops every source and schedules fresh ones from the new offset. That sounds wasteful and is actually free: creating a source node is cheap, and the decoded buffers never move.

The playhead is derived, never counted. Position is offset + (ctx.currentTime - startedAt), read off the audio clock on each animation frame. There is no timer to fall behind the audio, because the audio is the timer.

Mute and solo are arithmetic, not state machines. Each stem's effective gain is computed in one function: muted wins, otherwise any active solo silences everyone not soloed, otherwise the fader value. The result is pushed into the gain nodes with setTargetAtTime and a 15 millisecond ramp, because jumping a gain mid-note produces an audible click and a ramp that short is inaudible as a fade.

Memory gets two quiet decisions. The context runs at 32 kHz with a playback latency hint, which roughly quarters the decoded footprint of 44.1 kHz stereo and sounds fine for reviewing a mix. And stems decode one at a time rather than in parallel, because decoding everything at once briefly holds compressed and decoded copies of the whole song and can double peak memory. On a phone, peak memory is the difference between a player and a blank tab.

Even the fader cap is a product decision in one constant: FADER_MAX = 1.25, a slight boost past unity, because when you are auditioning a quiet part you want a little more than the mix gave you.

What the Platform Gave Us

Add it up and the division of labor is lopsided. Decoding a dozen compressed formats, resampling them to one rate, mixing n streams with per-stream gain, doing it all on a hardware-locked clock off the main thread: the browser's. Fetching files, bookkeeping state, and deciding what solo means: ours, in under three hundred lines.

The counterfactual was real. Audio libraries for the web are popular for good reasons, and reaching for one on day one would have felt like progress. But a library would have hidden exactly the layer we needed to understand, and its abstractions, built for games or one-shot players, would have fought the stem-player shape we wanted. The platform API was the right altitude: low enough to control scheduling, high enough that we never touched a sample.

The 900 Lines

Honesty requires the epilogue. That engine file is 1,221 lines today. The core did not change; what grew around it is the real world. Loading now overlaps network and decode in a small pipeline instead of a strict sequence. The engine leaves a marker in session storage when a load begins and clears it on success, because a tab that runs out of memory dies without any event, and finding a stale marker at the next visit is how the player knows to choose a lighter path instead of dying the same way twice. There is a clip detector with a memory, a pan knob the first version never needed, mix snapshots so a saved balance can be applied back onto the nodes, and the accumulated scar tissue of everything the Web Audio API does not tell you.

That ratio, 291 lines for the product's core and nine hundred for the edges, is the honest shape of platform work, and it is still a bargain. The edges would have existed with a library too, stacked on top of someone else's. Reach for the platform first. It has been rehearsing longer than any dependency you can install.

headshot of Kevin Peckham
Kevin Peckham
Principal, Lightning Jar

Ask Eljay

Ask about the studio's work, research, packages, or writing. A few starters:

Answers come from this site's own content and link their sources. For anything that matters, email hello@lightningjar.com.