cf.completefrontend
My progress
SYSTEM DESIGN · #121

Design a browser-based music studio

A music studio in a browser tab: tracks of recorded audio and MIDI notes on a timeline, a mixer with effects, recording from a microphone, a piano roll and an export to a file. Playing one sound is easy; the hard parts are timing that stays exact to the millisecond while the page is busy, a real-time audio thread you must never block, latency you have to measure and correct, and audio files that weigh tens of megabytes per minute.

The question

Design a music studio that runs in the browser, like BandLab Studio or Soundtrap. A user creates a project, adds tracks, records a guitar or a voice through their microphone, plays a MIDI keyboard or clicks notes into a piano roll, adds effects such as reverb and compression, and mixes the tracks with faders.

Walk me through how sound is produced and routed, how you keep notes in time while the UI is busy, how recording lines up with the music, how the project and its audio files are stored and saved, how you draw long waveforms, and how the user exports a finished song.

Real-life examples: BandLab Studio, Soundtrap, Audiotool, Ableton Learning Music, Chrome Music Lab Song Maker, Amped Studio

Requirements: what kind of studio are we building?

A DAW (digital audio workstation) is the program musicians use to record, arrange and mix songs. Desktop DAWs like Ableton Live, Logic Pro and FL Studio are native apps with direct access to the sound card. Our job is the same experience inside a browser tab, where we share one thread with the UI, cannot touch the sound card directly, and must ask permission for every microphone and MIDI device.

Several neighbouring guides already cover parts of this, and we link them instead of repeating them. The audio player guide covers the media element, Media Session and createMediaElementSource, and the video player guide covers turning media events into one player state and accessible transport controls. The photo editor guide covers non-destructive edit stacks and heavy work in workers. The undo and redo guide covers command history.

The Figma and Excalidraw guides cover canvas rendering, and Google Docs covers merging concurrent edits. The file uploader guide covers resumable chunked uploads, and the virtualized list guide covers drawing only the rows on screen. Here we focus on what is special about making music in a browser.

Start with questions. Each answer below changes a part of the design; the middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Who are the users?Hobby musicians, students and podcasters on laptops; some pros sketching ideasDefaults must work with built-in speakers and cheap USB microphones
Audio, MIDI, or both?Both: audio tracks with recorded or imported clips, and instrument tracks driven by MIDI notesTwo clip types, a sampler or synth engine, a piano roll
How big is a typical project?8 to 24 tracks, 3 to 6 minutes, 2 to 4 effects per trackCPU budget on the audio thread, memory for decoded audio
Recording?Yes, from a microphone or audio interface, one or two tracks at a time, against the playing songLatency measurement and take alignment
Hardware MIDI keyboards?Yes where the browser supports Web MIDI; mouse and computer keyboard everywhereFeature detection and a fallback input
Saved where?In the cloud, with work kept safe locally if the network dropsProject JSON plus binary assets, local cache, resumable uploads
Collaboration?Several people can edit the same project, but not play live togetherEdit sync like a document; no real-time jamming
Export?A WAV or MP3 of the whole song, rendered on the deviceOffline rendering and an encoder
Devices?Desktop Chrome, Edge, Firefox and Safari first; tablets and phones can play and do light editsMobile audio limits, smaller budgets

Functional requirements

  • Create a project with a tempo (for example 120 BPM, beats per minute) and a time signature such as 4/4; play, stop, loop a region and jump with a playhead.
  • Add audio tracks and instrument tracks; each has volume, pan, mute, solo and a chain of effects such as EQ (an equalizer that boosts or cuts frequency bands), compressor, delay and reverb.
  • Import audio files by drag and drop; move, trim, split and fade clips on the timeline without changing the original file.
  • Record from a microphone with a count-in and a metronome, and hear yourself while recording if you want to.
  • Play instruments from a MIDI keyboard or the computer keyboard; edit notes in a piano roll.
  • Draw automation: a fader or effect setting that changes over time, such as a fade out over the last 8 bars.
  • Undo and redo every edit; save automatically; reopen the project on another computer.
  • Export the mix to WAV or MP3, and export single tracks (stems).

Non-functional requirements

  • No audible glitches. A click or dropout in the music is the worst bug. The audio thread has about 2.7 ms to produce each block of 128 samples at 48 kHz, and it must never miss that deadline because the UI is busy.
  • Exact timing. Notes must land within about 1 ms of their place on the grid. Listeners hear rhythm errors of a few milliseconds on drums.
  • Low latency while playing and recording. Pressing a key on a MIDI keyboard should sound within about 20 ms (an estimate of where players start to notice). Recorded takes must line up with the music to within a millisecond or two after correction.
  • Never lose a take. A 4 minute vocal recording cannot be redone if the tab crashes; it must be on disk within seconds of being captured.
  • Responsive UI. Dragging clips and zooming a 5 minute timeline stay at 60 frames per second, with interactions answering within 100 ms.
  • Accessible: transport, faders and the track list work by keyboard and screen reader.

Out of scope

  • Live jamming with remote musicians in real time: the network is too slow, and we explain why with numbers in the architecture chapter.
  • Video scoring, notation (sheet music) and third-party desktop plugins such as VST: browsers cannot load native plugins.
  • Media playback basics, Media Session and lock screen controls: see the audio player guide.
  • The social feed, comments and publishing songs: separate product surfaces.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a music studio hard

Most web apps are allowed to be a little late. A feed that renders 50 ms late is fine. A drum hit that plays 50 ms late ruins the groove. Five traits of the problem drive every later decision.

  • Two clocks. The UI runs on the main thread, where timers fire late whenever a long task blocks it. Sound runs on a separate audio clock that never stops. Anything musical must be planned on the audio clock, not triggered by a main thread timer.
  • A hard real-time deadline. The browser asks the audio engine for 128 samples at a time. At 48,000 samples per second that is a new block every 2.67 ms. If any code on that path is late once, the speaker plays silence for that block and the user hears a click.
  • Latency everywhere. Sound takes time to leave the speakers (output latency) and to arrive from the microphone (input latency). Bluetooth headphones add 100 ms or more (an estimate; it varies by device and codec). Recording on top of a song means correcting for both.
  • Heavy data. One minute of stereo audio, decoded to 32-bit floats at 48 kHz, is 48,000 x 60 x 2 x 4 bytes = 23 MB. A 20 track, 4 minute project can hold well over a gigabyte of decoded audio if we are careless.
  • Long-lived, precious work. People spend hours in one session. A crash that loses a take or an hour of edits is worse than a slow page. Saving must be continuous and local first.

Here is the screen we are building. The transport bar sits at the top, track headers with name, mute, solo and arm-to-record buttons run down the left, and the timeline fills the middle with clips drawn as waveforms or note blocks. The mixer and the piano roll open in a bottom panel, and the browser panel on the right lists loops and imported files.

The studio screen. At the top, a transport bar with play, stop, record and loop buttons, the tempo 120 BPM in 4/4, and a CPU meter at 38 percent. On the left, three track headers: Drums, Vocals armed to record, and Synth. In the middle, the timeline with a drum loop clip, a vocal take clip and a MIDI notes clip, crossed by a vertical playhead. On the right, a browser panel with loops, files and samples. At the bottom, a mixer panel with decibel faders and a piano roll tab.Studio: Summer demoplaystoprecloop120 BPM, 4/4CPU 38%DrumsVocals, recSynthloop.wavtake 3notesplayheadloopsfilessamples-6-30-12mixer: faders in dB, meterspiano roll tab
The timeline is one view of the project and the sound is another. The transport and the playhead read the audio engine's clock; the clips, faders and notes are views of the project store.

Notice what the screen hides. The timeline is a view of a project document, like a document editor shows text. The sound you hear is a second view of the same document, produced by an audio engine that reads the project and plans the notes on the audio clock. Keeping those two views in step, while the user edits during playback, is the core of the design.

Think first

The user drags a clip 1 bar later while the song is playing. Which parts of the app must react, and in what order?

Producing sound: the Web Audio graph

The naive approach is one audio element per track: load each file, call play() on all of them, and set volume for the faders. It works for a demo with two loops. Then it breaks: each element starts when its own decoder is ready, so tracks drift apart by tens of milliseconds.

There is also no way to start a clip at an exact future time, no effects, no way to sum tracks into a master, and no way to render the mix to a file. The audio player guide uses one media element because a podcast is one stream. A studio mixes dozens of streams sample by sample.

The Web Audio API is built for this. You create an AudioContext, which owns a clock (currentTime, in seconds) and a rendering thread. You create nodes (sources, gains, filters, analysers) and connect them into a graph.

The browser pulls audio through the graph on its own thread, 128 samples at a time, and sends the result to the speakers. Your JavaScript never touches the samples unless you ask it to.

Decision What should produce and mix the sound?

One media element per track

An audio element per track or clip, started with play(), mixed by the operating system.

Pros

  • Tiny code, streams long files without decoding them fully
  • Works everywhere

Cons

  • No sample-exact start time: tracks drift
  • No effects chain, buses or offline render
  • Each element is a separate decoder and buffer

Web Audio graph of native nodes

Decode clips to AudioBuffers, play them with AudioBufferSourceNode.start(when, offset, duration), route through GainNode, StereoPannerNode, BiquadFilterNode, DynamicsCompressorNode, ConvolverNode into a master.

Pros

  • Sample-exact scheduling on the audio clock
  • Native nodes are written in C++ and run on the audio thread
  • Same graph can render offline for export

Cons

  • Decoded audio is large in memory
  • Only the effects the browser ships, unless you add worklets

One big AudioWorklet engine (often WASM)

A single AudioWorkletNode runs your own mixer and effects, often compiled from C++ or Rust to WebAssembly, and reads samples from shared memory.

Pros

  • Full control: any effect, any routing, the same code as a desktop app
  • Predictable cost per block

Cons

  • You rebuild what the browser gives for free
  • Needs SharedArrayBuffer for streaming data in, so the site must be cross-origin isolated (the COOP and COEP headers, covered below)
  • Much harder to debug

What we pick: Start with the native graph: it gives exact timing, cheap mixing and offline export with little code. Add AudioWorklet nodes inside it only where we need custom processing (a meter, a special effect, recording raw samples). A team porting an existing desktop engine to the web may well pick the third option; that is a business choice, not a starting point.

Channel strips, buses and the master

A studio graph mirrors a hardware mixing desk. Every track gets a channel strip: an input gain, an insert chain of effects, a pan node, a fader gain, a mute gain and an analyser for its meter. Strips feed buses (a reverb bus that several tracks send a little signal to, a drums bus that groups the kit) and every bus feeds the master strip, which has a limiter so the mix never clips, and then audioContext.destination, the speakers.

The Web Audio graph. Three kinds of sources, drum hits, a vocal take and synth voices, each made of one-shot source nodes, feed three channel strips: Drums, Vocals and Synth, each with gain, EQ, pan and fader. All three strips feed the Master, which has a limiter and feeds the output. The Synth strip also sends a little signal at minus 12 dB to a Reverb bus with a convolver, and the bus feeds the Master too.sourceschannel stripsbus and masterdrum hitsvocal takesynth voicesDrums stripgain, EQ, pan, faderVocals stripgain, EQ, pan, faderSynth stripgain, EQ, pan, faderMasterlimiterReverb busconvolveroutputsend -12 dBan AnalyserNode hangs off each strip for its meter
Sources are created per note or clip and thrown away; strips, buses and the master live as long as the project. Reverb sits on one shared bus that tracks send to, because a ConvolverNode is expensive.
ts
function createStrip(ctx: AudioContext, out: AudioNode) {
  const input = ctx.createGain();      // clips connect here
  const pan = new StereoPannerNode(ctx);
  const fader = ctx.createGain();      // volume
  const mute = ctx.createGain();       // 0 or 1, kept separate from the fader
  const meter = new AnalyserNode(ctx, { fftSize: 1024 });
  input.connect(pan).connect(fader).connect(mute);
  mute.connect(out);
  mute.connect(meter);                 // meter is a dead end, not on the path
  return { input, pan, fader, mute, meter, inserts: [] as AudioNode[] };
}

function setFaderDb(strip: Strip, db: number, ctx: AudioContext) {
  const gain = db <= -60 ? 0 : 10 ** (db / 20);
  // a short ramp avoids a click when the value jumps
  strip.fader.gain.setTargetAtTime(gain, ctx.currentTime, 0.01);
}
A channel strip is built once per track and lives as long as the track.

Two details in that code matter. Faders are shown in decibels but a GainNode wants a linear factor, so -6 dB becomes about 0.5. And we never set gain.value directly during playback: a jump from 1 to 0 in one sample makes an audible click, so we ramp over about 10 ms with setTargetAtTime or linearRampToValueAtTime. Every node property that can change over time is an AudioParam, and its scheduled ramps are also how automation lanes play back.

Sources are cheap and one-shot

The surprise for most people is that an AudioBufferSourceNode can be started only once: calling start() a second time throws an InvalidStateError. That is by design, because source nodes are meant to be thrown away. The decoded AudioBuffer is the expensive, shared part; the source node that plays it is a few bytes.

So each time a clip plays, or a drum sample hits, the engine creates a new source, connects it to the track strip and starts it at an exact time. After it ends, it is disconnected and garbage collected.

ts
function playClip(ctx: AudioContext, buffer: AudioBuffer, strip: Strip,
                  when: number, offset: number, duration: number) {
  const src = new AudioBufferSourceNode(ctx, { buffer });
  src.connect(strip.input);
  src.start(when, offset, duration);   // seconds on ctx.currentTime's clock
  src.onended = () => src.disconnect();
  return src;                          // kept so stop() can cancel it on edits
}
Play part of a clip at an exact time on the audio clock.

The context starts suspended

Browsers block sound until the user interacts with the page (the autoplay policy). An AudioContext created on page load usually starts in the suspended state, and its clock does not move. Call ctx.resume() inside the handler of the first click or key press, for example on the Play button, and check ctx.state before playing. Create one context for the whole app and keep it: each context holds a real audio device stream, and creating many wastes resources.

Check your understanding · Exercise 2

A user loops a 1 bar drum clip. Which statements about the engine are true? Select all that apply.

Keeping time: the look-ahead scheduler

The naive sequencer uses a timer: at 120 BPM a sixteenth note lasts 125 ms, so setInterval(playNextNote, 125) should do. Try it while the user drags a clip and React re-renders the timeline. A 40 ms long task delays the timer by 40 ms, so that hi-hat lands 40 ms late, then the next one is on time again. The beat stumbles.

Timers in JavaScript promise a minimum delay, never an exact one, and hidden tabs throttle them further. The audio clock does not have this problem. audioContext.currentTime advances in steady 128-sample steps on the audio thread, whatever the main thread is doing, and start(when) takes a time on that clock.

So the fix is to split the job between two clocks, as Chris Wilson described in his article 'A Tale of Two Clocks'. A sloppy main thread timer wakes up often, and on each wake-up it schedules every note that falls in the next short window, using exact times on the audio clock. The timer can be late; the notes cannot.

Decision How should the engine decide when each note plays?

Fire each note from a timer

setTimeout or setInterval per note; the callback calls start() with no time, meaning now.

Pros

  • Simple; edits apply instantly

Cons

  • Every main thread stall becomes a timing error you can hear
  • Background tabs throttle timers

Schedule the whole song at once

On Play, walk every note and clip and call start(when) for all of them.

Pros

  • Perfect timing; no timer at all

Cons

  • Thousands of live nodes and their memory up front
  • Every edit, tempo change or stop must cancel and rebuild everything
  • Loops never end, so they cannot be pre-scheduled

Look-ahead scheduler

A timer wakes every 25 ms and schedules events whose time falls before currentTime + 0.1, with exact when values; it remembers how far it got.

Pros

  • Exact timing, as long as no stall is longer than the overlap
  • Only about 100 ms of audio committed, so edits and tempo changes apply almost at once
  • Loops are natural

Cons

  • A stall longer than the look-ahead still causes late notes
  • More logic: cursors, cancellation, loop wrap

What we pick: Use the look-ahead scheduler. Wilson's metronome uses a 25 ms interval and schedules 100 ms ahead, so a main thread stall of up to about 75 ms costs nothing. A longer look-ahead survives worse stalls but makes the engine slower to react to edits; a shorter interval costs more wake-ups. The lab below lets you feel that trade.

Two clocks over 300 milliseconds. On the main thread, a timer ticks every 25 ms, but a 70 ms long task from 100 to 170 ms blocks three ticks, and the next tick runs late at 170 ms. The tick at 75 ms already scheduled everything up to 175 ms, and the late tick schedules up to 270 ms. On the audio clock, five notes at 0, 62.5, 125, 187.5 and 250 ms all play exactly on time.main threadlong task, 70 ms+100 ms+100 ms, late tickaudio clock0100 ms200 ms300 msnotes keep exact times; only the timer moved
Each tick schedules notes for the next 100 ms with exact start times. The long task delays the timer, but the windows overlap, so no note is late.
ts
const LOOKAHEAD_S = 0.1;   // schedule this far ahead of the audio clock
const INTERVAL_MS = 25;    // how often the main thread wakes up

function tick() {
  const horizon = ctx.currentTime + LOOKAHEAD_S;
  while (true) {
    const ev = song.nextEventAtOrAfter(nextTick);
    if (!ev) break;
    const when = anchor.ctxTime + tempoMap.secondsBetween(anchor.tick, ev.tick);
    if (when >= horizon) break;
    voices.add(engine.play(ev, Math.max(when, ctx.currentTime)));
    nextTick = ev.tick + 1;
  }
}
const timer = setInterval(tick, INTERVAL_MS); // or post ticks from a Worker
The scheduler loop. nextTick is how far into the song we have scheduled.
Long tasks:
32 sixteenth notes at 120 BPM over 4 seconds, with four main-thread long tasks of 120 ms. Timer per note: worst 88 ms late, 4 notes over 5 ms late. Look-ahead of 100 ms with a 25 ms timer: worst 35 ms late, 1 notes over 5 ms late, 145 wake-ups.timer per note: worst 88 ms, 4 latelook-ahead: worst 35 ms, 1 late0 s1 s2 s3 s4 s100 ms of audio committed ahead; 145 timer wake-ups in 4 s
The long tasks (120 ms) outlast look-ahead minus interval (75 ms), so even look-ahead notes come late. A longer look-ahead fixes it, but edits and tempo changes then take longer to be heard.

From bars and beats to seconds

Musicians think in bars and beats, not seconds, and the project stores positions the same way, as integer ticks. A common resolution is 960 ticks per quarter note (PPQ). Storing ticks means a tempo change moves every note with the music, which is what a musician expects. The engine converts ticks to seconds only at the moment it schedules.

Worked example at 120 BPM in 4/4: one beat is 60 / 120 = 0.5 s, so one tick is 0.5 / 960 = about 0.52 ms. Bar 3, beat 2 is 2 full bars (8 beats) plus 1 beat = 9 beats = 8,640 ticks = 4.5 s from the start. A sixteenth note is 240 ticks, 125 ms.

Tempo can change during a song, so the project has a tempo map: a sorted list of points, each with a tick and a BPM. Converting tick to seconds walks the segments and adds up each segment's length at its own tempo. Precompute the seconds at each tempo point once per edit, then a lookup is a binary search plus one multiply. Gradual tempo ramps need an integral over the ramp; keep them out of the first version unless the interviewer asks.

ts
type TempoPoint = { tick: number; bpm: number; startSec: number };
const PPQ = 960;

function tickToSeconds(map: TempoPoint[], tick: number): number {
  let i = map.length - 1;
  while (i > 0 && map[i].tick > tick) i--; // binary search in real code
  const p = map[i];
  return p.startSec + ((tick - p.tick) / PPQ) * (60 / p.bpm);
}

Loops, stops and edits during playback

The scheduler keeps an anchor: one pair that says song tick T plays at context time C. When the scheduling window crosses the loop end, the scheduler schedules the remaining events up to the loop end, then moves the anchor so the loop start tick maps to the context time where the loop end would have played, and continues. No gap, no double hit. A tempo change during playback also just moves the anchor to the current position.

Every source the scheduler starts goes into a voices set with its start time. On Stop, the engine calls stop() on every voice and ramps the master down over 10 ms. On an edit, it stops voices that belong to the changed clip and have not started yet, rewinds nextTick to the current position, and lets the next tick re-plan. That is the payoff of a short look-ahead: at most 100 ms of audio is ever committed.

Drawing the playhead from the audio clock

The playhead is drawn in a requestAnimationFrame loop that reads the audio clock, never counted with its own timer. ctx.currentTime is not what the user hears: samples processed now reach the speaker only after the output latency. With Bluetooth headphones that difference can be 100 ms or more, and a playhead that ignores it runs visibly ahead of the sound.

ctx.getOutputTimestamp() returns contextTime, the audio clock position of the sample the speaker is playing now, with the matching performance.now() time, so draw from that pair. A rough fallback is ctx.currentTime - ctx.baseLatency - ctx.outputLatency. Feature detect outputLatency: Chrome and Firefox have had it for years, but Safari added it only in version 18.4.

Check your understanding · Exercise 3

The scheduler wakes every 25 ms and schedules 100 ms ahead. Which of these cause audible late notes? Select all that apply.

Custom processing on the audio thread: AudioWorklet

Native nodes cover mixing, filters, delay, compression and convolution reverb. Sooner or later a studio needs something they do not have: an accurate peak and loudness meter, a tuner, a distortion with a custom curve, a synth voice, or a way to get raw microphone samples for recording. That code must run on the audio rendering thread, inside the 128-sample deadline.

The old answer was ScriptProcessorNode, which ran your callback on the main thread. Any long task made it miss the deadline and the audio crackled, and it is now deprecated. The current answer is AudioWorklet: you load a module with ctx.audioWorklet.addModule('meter.js'), and the browser runs your AudioWorkletProcessor class on the audio thread itself.

Its process(inputs, outputs, parameters) method is called once per render quantum, the block of frames the engine renders at a time. The quantum is 128 frames by default, about 2.67 ms at 48 kHz and 2.9 ms at 44.1 kHz. Chrome 153 added a renderSizeHint option that asks for a different size (read the result from ctx.renderQuantumSize), and other browsers may follow, so read the block length from the arrays instead of hard-coding 128.

js
class PeakMeter extends AudioWorkletProcessor {
  constructor() {
    super();
    this.peak = 0;
    this.frames = 0;
  }
  process(inputs, outputs) {
    const input = inputs[0];
    for (let ch = 0; ch < input.length; ch++) {
      const data = input[ch];
      outputs[0][ch]?.set(data);           // pass audio through
      for (let i = 0; i < data.length; i++) {
        const v = Math.abs(data[i]);
        if (v > this.peak) this.peak = v;
      }
    }
    this.frames += outputs[0][0].length;   // 128 unless the context chose another size
    if (this.frames >= 2048) {             // about 23 times a second at 48 kHz
      this.port.postMessage(this.peak);
      this.peak = 0;
      this.frames = 0;
    }
    return true;                           // keep the node alive
  }
}
registerProcessor("peak-meter", PeakMeter);
A peak meter processor. The hot loop allocates nothing; the only message is a level about 23 times a second.

The rules of the audio thread

  • Never allocate in process(). Creating arrays or objects every 2.7 ms feeds the garbage collector, and a collection pause on the audio thread is a dropout. Allocate buffers once in the constructor and reuse them.
  • Never block or wait. No locks, no await, no network, no console.log in the hot path. The thread cannot wait for anything.
  • Keep the cost predictable. If processing one block takes 1 ms, you have used 37% of the deadline for one node. With 24 tracks you have no budget left.
  • Talk to the main thread rarely. port.postMessage copies data and allocates; it is fine 20 to 60 times a second for meters, not for every block of audio.

Decision How should large streams of samples move between the audio thread and other threads?

postMessage per block

The processor posts each 128-frame block to the main thread or a worker, which collects them.

Pros

  • Works on any site, no special headers
  • Easy to write

Cons

  • About 375 messages per second at 48 kHz, each one allocated and copied
  • The receiver runs on the busy main thread; a stall builds a backlog

SharedArrayBuffer ring buffer

Both threads see the same memory. The producer writes samples and advances a write index with Atomics.store; the consumer reads up to it with Atomics.load and advances a read index.

Pros

  • No copies, no allocation, no messages on the audio thread
  • A worker can drain it at its own pace

Cons

  • Requires a cross-origin isolated page (COOP and COEP headers)
  • Must size the buffer for the worst stall and handle overflow

What we pick: Use postMessage for small, slow data like meter levels. Use a SharedArrayBuffer ring buffer for anything that streams samples: recording raw audio, feeding a WASM engine, or streaming long files into a worklet. That means planning the cross-origin isolation headers from day one, because adding them later can break third-party embeds.

A ring buffer in a SharedArrayBuffer, shown as 8 slots. The recorder worklet on the audio thread writes blocks and moves the write index, now at slot 6. The media worker reads from the read index, now at slot 2, up to the write index. Slots 2 to 5 hold blocks 12 to 15 waiting to be read. After the last slot, both indexes wrap to slot 0.SharedArrayBuffer: 8 slots here, about 1 s of audio in real lifeMedia workerreads, writes OPFSRecorderaudio thread, writesb12b13b14b15read = 2write = 6wraps to 0
Each index has one writer, so no lock is needed: the worklet publishes its index with Atomics.store, the worker reads it with Atomics.load. If the worker stalls longer than the buffer holds, the writer must drop blocks and report an overflow.

A ring buffer is a fixed array used in a circle. The writer puts samples at the write index and moves it forward, wrapping to 0 at the end, and the reader takes samples from the read index up to the write index. Each index is written by only one thread, so no lock is needed: Atomics makes sure each thread sees the other's index update in the right order.

Size it for the longest stall you expect on the consumer. One second of stereo float audio at 48 kHz is 384 KB, cheap insurance.

Cross-origin isolation

After the Spectre attacks became public in 2018, browsers turned SharedArrayBuffer off. Today they expose it only on pages that are cross-origin isolated, which you check with self.crossOriginIsolated. The page must be served with two headers, COOP (Cross-Origin-Opener-Policy) and COEP (Cross-Origin-Embedder-Policy), explained in the security-headers lesson.

http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
# or, in Chrome and Firefox (not Safari): Cross-Origin-Embedder-Policy: credentialless

The cost: every cross-origin resource the page loads (sample packs on a CDN, avatars, fonts, analytics) must send Cross-Origin-Resource-Policy or CORS headers, or it fails to load. Popups opened to other origins lose their link back to the page, which can break OAuth sign-in popups. A common layout is to isolate only the studio route and keep the marketing site and sign-in pages normal.

Check your understanding · Exercise 4

Which of these belong inside an AudioWorkletProcessor's process() method? Select all that apply.

Latency and recording a take that lines up

A singer presses Record, hears the song in headphones and sings along perfectly in time. Playback of the take is 40 ms late. Nothing is broken: the take is late by exactly the time sound spent travelling.

The song left the engine, waited in the output buffers, and reached the ears after the output latency. The voice reached the microphone, went through the converter and the input buffers, and reached our code after the input latency. The sum is the round-trip latency, and every recorded sample arrives that late.

Where the milliseconds go

PartHow to read itTypical size (estimates)
Browser buffering before the OSctx.baseLatencyA few ms with latencyHint: "interactive"
Output: OS buffers, device, cablectx.outputLatency (feature detect; Safari 18.4 and later)5 to 30 ms on wired outputs; 100 to 300 ms on Bluetooth
Input: converter and OS buffersNot reliably exposed; measure it5 to 30 ms
Round trip (what recording sees)Measured with a loopback test15 to 60 ms on laptops; lower with an audio interface

The latencyHint option of the AudioContext constructor trades latency for stability. "interactive", the default, asks for the smallest buffers that do not glitch; "balanced" and "playback" ask for bigger buffers, which save power and survive heavy load but add delay. You can also pass a number of seconds.

A studio wants "interactive" while recording or playing a MIDI keyboard, and can offer a 'safe mode' with "playback" for slow machines that crackle. The context must be recreated to change it, so do it only when the transport is stopped.

Capturing the microphone

navigator.mediaDevices.getUserMedia({ audio: ... }) asks permission and returns a stream. Its defaults are tuned for video calls, and they damage music: echo cancellation removes anything that sounds like the speakers, noise suppression eats quiet guitar notes and reverb tails, and automatic gain control pumps the level up and down. Turn all three off, ask for the user's chosen device, and ask for stereo when the device has two inputs.

ts
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    deviceId: { exact: settings.inputDeviceId },
    echoCancellation: false,
    noiseSuppression: false,
    autoGainControl: false,
    channelCount: { ideal: 2 },
  },
});
const mic = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream });
mic.connect(recorderNode); // an AudioWorkletNode that writes PCM to a ring buffer

Decision How should the app capture the recorded audio?

MediaRecorder

new MediaRecorder(stream) encodes the input to a compressed file and emits chunks through ondataavailable.

Pros

  • Few lines of code; small files
  • Encoding runs off the main thread

Cons

  • A codec the browser supports, usually lossy: Opus in WebM on Chrome, Firefox and Safari 18.4 and later, AAC in MP4 on older Safari; check MediaRecorder.isTypeSupported()
  • Its start is not tied to the audio clock, so alignment is guesswork
  • Must be decoded again before editing

Raw PCM through an AudioWorklet

The microphone node feeds a recorder processor that copies each 128-frame block into a ring buffer, stamped with the audio clock time; a worker drains it and writes chunks to disk.

Pros

  • Lossless, exactly the samples the engine saw
  • Every sample has a known position on the audio clock, so alignment is exact
  • Ready to play at once; no decode

Cons

  • Big: about 23 MB per minute of stereo float audio, so store as 16 or 24 bit
  • More code: worklet, worker, chunked storage

What we pick: Capture raw PCM through a worklet: a studio needs lossless takes and sample-exact timing, and the size is manageable when written to disk in chunks as 24-bit or 16-bit. Encode to a compressed format only later, for upload previews. MediaRecorder stays useful for a quick voice memo feature.

Lining up the take

The recorder stamps the first captured block with its audio clock time. That tells us where the samples arrived, not when the singer sang them. To correct, shift the take earlier by the round-trip latency: keep the clip's start at the record position and set offsetSec = roundTrip. Playback then skips the first few milliseconds of the file, and every sample lands where it was sung.

The number comes from a one-time loopback calibration. The app plays a few short clicks and records them back (through a cable from output to input, or the laptop microphone next to its speaker), finds each click's peak in the recording, and takes the median distance. Store the result per pair of input and output device, and run it again when the devices change.

Sequence diagram for recording a take, between the user, the UI, the recorder worklet, the media worker and OPFS storage. The user presses Record. The UI, which already has the microphone and has played the count-in, tells the recorder to start capture. The recorder sends PCM blocks through the ring buffer to the media worker, which appends 1 second chunks to OPFS, and OPFS confirms each flush. Failure path: the microphone track ends because the device was unplugged; the UI asks the media worker to finalize the take from the chunks already saved, and tells the user the take was kept even though the input was lost.UserUIRecorderMedia workerOPFSpress Recordstart capturePCM blocksappend chunkflushedtrack endedfinalize taketake kept
Chunks reach disk every second while recording, so the failure path loses almost nothing. The take's first block carries its audio clock time, which the UI uses with the measured latency to place the clip.

Monitoring means hearing yourself while recording. Through the browser, monitoring adds the full round trip, so a singer hears their own voice 30 ms late, which feels like a slap-back echo. Offer it as a switch, off by default, and suggest the audio interface's direct monitoring when one is connected. Never monitor through laptop speakers: the microphone picks them up and the loop howls.

The recording state machine

Recording has more states than a boolean can hold, and the bugs live in the transitions: a device unplugged mid-take, permission revoked, the tab hidden. Model it as a state machine, as the state-machines lesson describes, and drive the UI from the state: the record button, the count-in display and the disabled transport.

Recording state machine. Idle goes to asking for the microphone when the user presses Record. Asking goes to armed when allowed, or to denied when refused; denied explains and offers a retry. Armed goes to count-in when playback starts. Count-in goes to recording when the bar ends. Recording goes to finalizing on Stop or when the device is lost. Finalizing saves the take and returns to idle. If the tab crashes while recording, chunks are already on disk and are recovered on reload.idleaskingarmedcount-inrecordingfinalizingdeniedexplain, retryRecordallowedplaybar endsrefusedStop, or device lostsavedtab crash mid-take:chunks on disk, recover
Device loss is not an error state that throws the take away: it is just another way into finalizing. The only state that needs the user's help is denied.

The most important rule is in the recording state: the worker writes each chunk of about 1 second to disk as it arrives, in the Origin Private File System (OPFS, covered in the storage chapter) or IndexedDB. If the tab crashes in minute 4 of a take, at most the last second is lost, and on reload the app finds an unfinished take and offers to keep it.

Check your understanding · Exercise 5

A user records a guitar part and it plays back 35 ms late. Which fixes are correct? Select all that apply.

MIDI input, instruments and the piano roll

MIDI is the protocol, over 40 years old, that keyboards and drum pads speak. It carries no sound, only events: 'note 60 (middle C) on, velocity 100', 'note 60 off', 'controller 64 (sustain pedal) on'. An instrument track turns those events into sound, and the project stores them as notes in a clip.

Web MIDI: support and permission

navigator.requestMIDIAccess() returns the connected inputs and outputs. Support is uneven: Chrome and Edge have it, Firefox has it since version 108 (the user approves a small site permission add-on), and Safari on macOS and iOS does not support it at all. It needs a secure context (HTTPS).

Chrome used to prompt only for SysEx (system exclusive messages, which can read and rewrite a device's presets and firmware). From Chrome 124 it rolled out a prompt for any MIDI access, and asking with { sysex: true } still needs its own stronger grant, so request SysEx only if you edit device presets. All of this means the design must treat MIDI hardware as an enhancement.

  • Feature detect 'requestMIDIAccess' in navigator; if it is missing, hide the MIDI settings and say why in one line.
  • Ask for access only when the user opens the MIDI settings or arms an instrument track, never on page load: a prompt with no context gets denied.
  • Listen to statechange on the access object so a keyboard plugged in mid-session appears without a reload.
  • Always offer two fallbacks that work everywhere: the computer keyboard (the A to K row plays a white-key octave, Z and X shift octaves) and an on-screen keyboard you can click or tap.
ts
access.inputs.forEach((input) => {
  input.onmidimessage = (e) => {
    const [status, note, velocity] = e.data;
    const type = status & 0xf0;
    if (type === 0x90 && velocity > 0) instrument.noteOn(note, velocity / 127);
    else if (type === 0x80 || (type === 0x90 && velocity === 0)) instrument.noteOff(note);
    if (recording) takeNotes.push({ note, velocity, at: e.timeStamp, type });
  };
});

Two timing details. For live playing, noteOn starts the voice at ctx.currentTime, as soon as possible, because the player is listening. For recording, the event's timeStamp is on the performance.now() clock, not the audio clock.

Convert it with the pair from ctx.getOutputTimestamp(), which maps a performance.now() time to the audio position the player was hearing at that moment, then to song ticks with the tempo map. Notes then land where the player meant them, not where the main thread happened to process them.

Sequence diagram for playing a MIDI keyboard while recording. The player presses a key, and the MIDI keyboard sends note on 60 with velocity 100 to the main thread. The main thread starts a voice on the audio thread right now, and the player hears the sound. Because recording is on, the main thread converts the event's timestamp to ticks and adds a note at tick 15,360 to the project store. When the key is released, note off 60 goes to the main thread, which starts the voice's release envelope. The store tells the piano roll to re-render.PlayerMain threadAudio threadStorenote on 60start voice nowsound outnote at 15,360note off 60release envelopepiano roll updates
Live sound uses the current time, because the player is listening. The stored note uses the event's timeStamp, converted to the audio clock and then to ticks, so the note lands where it was played, not where the main thread got to it.

Decision How should instrument tracks turn notes into sound?

Sampler built from native nodes

Recorded notes of a real instrument, a few per octave, decoded to AudioBuffers. Each note starts a new source with playbackRate set to bend the nearest sample to the right pitch, plus a gain envelope.

Pros

  • Realistic pianos, drums and strings
  • Cheap per voice; all native code

Cons

  • Sample sets weigh 5 to 100 MB (an estimate), so they load lazily and cache
  • Pitching a sample far changes its character

Synth built from native nodes

OscillatorNodes through a BiquadFilterNode and a GainNode envelope driven by AudioParam ramps (attack, decay, sustain, release).

Pros

  • No downloads; tiny
  • Every knob maps to an AudioParam, so it can be automated

Cons

  • Several nodes per voice; hundreds of voices add up
  • Limited to what the native nodes can do

Synth in an AudioWorklet (often WASM)

One processor renders all voices of the instrument per block, with your own oscillators and filters.

Pros

  • Any sound design; one node no matter how many voices
  • Can reuse a desktop synth's DSP code

Cons

  • All the audio-thread rules apply
  • More engineering, harder to debug

What we pick: Ship a sampler for drums, piano and acoustic sounds and a native-node synth for basic sounds, with a polyphony cap of about 32 voices per instrument that steals the oldest voice. Move a flagship synth into a worklet later if it needs features native nodes cannot give.

The piano roll

The piano roll is a grid: pitch on the vertical axis (a keyboard drawn down the left edge), time in ticks on the horizontal axis, notes as rectangles whose length is their duration and whose shade is their velocity. It is a canvas editor, much like the ones in the Figma and Excalidraw guides: draw the grid and notes on a canvas, hit-test pointer events against note rectangles, and keep the selection in the editor's own state.

  • Snap and quantize. Clicks snap to the grid value (a sixteenth is 240 ticks at 960 PPQ). Quantize moves recorded notes toward the grid by a strength percentage, so a human feel survives.
  • Draw only what is visible. A clip can hold thousands of notes. Keep notes sorted by start tick and binary search the visible range; the drawing cost then depends on the screen, not the song.
  • Audition on edit. Dragging a note up plays its new pitch briefly, through the same instrument the track uses.
  • Edits are commands. Moving 20 notes is one command with the before and after values, so undo restores them in one step, as the undo and redo guide describes.

Check your understanding · Exercise 6

Your studio must work for a teacher's class on school iPads using Safari. What does that mean for MIDI? Select all that apply.

The project model: a small document pointing at big files

Everything so far reads from one source of truth: the project. Get its shape right and saving, undo, collaboration and export all become simple. The key split is between the arrangement, which is small, edited constantly and must sync, and the audio, which is huge, written once and never changed.

Decision How should a project and its audio be stored?

One bundle file

Serialize the arrangement and every audio file into one archive, like a desktop project folder zipped.

Pros

  • Easy to download and share as one file

Cons

  • Saving a fader move rewrites hundreds of MB
  • No partial loading; collaboration impossible

JSON document plus immutable assets by id

The arrangement is a JSON document of tracks, clips, notes and automation. Clips point to audio assets by id (a content hash), stored once as binary files.

Pros

  • A save is a few KB
  • An asset uploads once and is shared by every clip, copy and collaborator
  • Edits are non-destructive: trimming a clip changes numbers, not audio

Cons

  • Two kinds of storage to keep consistent
  • Needs garbage collection for unused assets

What we pick: Use the JSON document plus immutable assets. It is the same idea as the photo editor's edit stack: the source pixels or samples never change, the document describes what to do with them. Offer a bundle only as an export format.

ts
type Tick = number;                          // 960 per quarter note
type Project = {
  id: string; version: number; sampleRate: 48000 | 44100;
  tempoMap: { tick: Tick; bpm: number }[];
  timeSignature: { tick: Tick; beats: number; unit: number }[];
  tracks: Track[];                            // ordered top to bottom
  buses: Bus[]; master: Strip; loop?: { start: Tick; end: Tick };
};
type Track = {
  id: string; name: string; kind: "audio" | "instrument";
  strip: Strip; instrument?: { preset: string; params: Record<string, number> };
  clips: Clip[]; automation: AutomationLane[]; frozenAssetId?: string;
};
type Strip = { volumeDb: number; pan: number; mute: boolean; solo: boolean;
  inserts: { id: string; type: string; params: Record<string, number>; bypass: boolean }[];
  sends: { busId: string; db: number }[] };
type AudioClip = { id: string; type: "audio"; assetId: string; start: Tick;
  offsetSec: number; durationSec: number; gainDb: number; fadeIn: number; fadeOut: number };
type MidiClip = { id: string; type: "midi"; start: Tick; length: Tick;
  notes: { pitch: number; start: Tick; length: Tick; velocity: number }[] };
type Clip = AudioClip | MidiClip;
type AutomationLane = { target: string; points: { tick: Tick; value: number; curve: "linear" | "hold" }[] };
type Asset = { id: string; sampleRate: number; channels: number; frames: number;
  original: { mime: string; bytes: number }; peaksReady: boolean };
  • Clip times mix units on purpose. A clip's start is in ticks, so it moves with the music when the tempo changes. Its offsetSec and durationSec are in seconds of the source audio, because a recorded guitar does not speed up when you change the tempo. (Time-stretching audio to follow tempo is a separate, expensive feature.)
  • Automation lanes are breakpoints. A lane targets one parameter, like track.t3.volumeDb, with points in ticks. The scheduler turns the points inside its window into linearRampToValueAtTime and setValueAtTime calls on the matching AudioParam.
  • Assets are content addressed. The id is a SHA-256 hash of the original file. Importing the same loop twice stores it once, and an upload can skip files the server already has.
  • Ids, not indexes. Every track, clip and note has a stable id, so undo commands and collaborators can point at them even after reordering.
The project data model. A Project holds the tempo map, buses and master, and a list of Tracks. Each Track has a strip with inserts and sends, Automation lanes with points in ticks, and Clips. An Audio clip has a start tick and an offset and duration in seconds, and points by assetId to an Asset with its hash, frames and peaks. A MIDI clip holds notes in ticks. Asset files, originals and peaks, live in OPFS and the object storage bucket.ProjecttempoMap, busesTrackstrip, inserts, sendsAudioClipstart tick, offset sAutomationpoints in ticksAssetsha256, frames, peaksMidiClipnotes in ticksasset filesOPFS and bucketassetIdtracksclips
Every box is small JSON that changes all the time and syncs as operations. The asset files are big, written once, and shared by id.

The sizes explain where each piece lives. The document is small enough to keep whole in memory and save on every edit. The audio is too big for memory, so it stays on disk and is decoded only near the playhead.

DataSize for a 4 minute, 16 track song (estimates)Lives in
Project JSON20 to 200 KBProject store in memory, IndexedDB copy, server database
Original audio assets (compressed or 24-bit)50 to 500 MBOPFS or IndexedDB, object storage on the server
Decoded audio for playbackUp to 23 MB per stereo minute of distinct audioMemory only, decoded on demand
Waveform peaksUnder 1 percent of decoded sizeIndexedDB or OPFS, regenerated if missing
UI state: zoom, scroll, selection, open panelsBytesComponent state; zoom and scroll per project in local storage

Check your understanding · Exercise 7

The user changes the tempo from 100 to 120 BPM. Which statements are true for this model? Select all that apply.

Drawing waveforms on a long timeline

A clip on an audio track shows its waveform, so the user can see where the drum hits and the words are. The naive version loops over the decoded samples on every frame and draws a line through them. A 5 minute stereo clip at 48 kHz has 14.4 million samples per channel.

Zoomed out to fit a 1,200 pixel timeline, each pixel covers 12,000 samples, and reading all of them on every scroll frame costs tens of milliseconds per track and blocks the UI. It also needs the whole clip decoded in memory, 115 MB, just to draw a picture.

What a pixel column actually shows is the loudest and quietest sample in its time range: a vertical line from the minimum to the maximum. So we precompute peaks: split the audio into buckets of N samples and keep only the min and max of each bucket. Drawing then reads one pair per pixel, not thousands of samples.

Decision How should waveform data be prepared for drawing?

Scan samples at draw time

Keep the decoded buffer; for each visible pixel, find min and max of its samples on every redraw.

Pros

  • No extra data; exact at every zoom

Cons

  • Cost grows with zoom-out; slow on long clips
  • Needs decoded audio in memory for every visible clip

One peak resolution

A worker computes min and max per 512 samples once, after import or recording.

Pros

  • Small and simple

Cons

  • Zoomed out to a whole song, still dozens of buckets per pixel to merge on every redraw
  • Zoomed far in, too coarse; individual samples blur

A pyramid of peak levels

A worker computes levels at 256, 1,024, 4,096 and 16,384 samples per bucket, each built from the level below. Drawing picks the coarsest level whose bucket still covers no more than one pixel, then merges the few buckets that fall in each pixel.

Pros

  • Every zoom reads about 1 to 4 buckets per pixel
  • Tiny: the whole pyramid is about a third bigger than the finest level

Cons

  • Needs a worker job per asset and storage for the result
  • Below 256 samples per pixel, read the real samples instead

What we pick: Build the pyramid in a worker as soon as an asset is imported or a take is saved, store it next to the asset, and stream the finest level in while a recording is in progress. The same idea as image tiles at several zoom levels in a map.

The waveform peak pyramid for a 5 minute stereo clip, not to scale. The raw samples, 14.4 million per channel, take 115 MB as decoded floats. The 256 samples per bucket level keeps a min and a max per bucket in 225 KB. The 1,024 level takes 56 KB. The 4,096 and 16,384 levels take about 14 KB and 4 KB. The whole pyramid is about 300 KB.samples: 14.4 million per channel, 115 MB decoded256 per bucket: min and max, 225 KBmin, maxmin, maxmin, maxmin, max1,024 per bucket: 56 KBmin, max4,096 and 16,384 per bucket: about 14 KB and 4 KBstored as Int8Array per channel; whole pyramid about 300 KB
Each level is built from the one below, so the worker reads the samples only once. The whole pyramid is about 0.3 percent of the decoded audio, small enough to store with the asset and load before any audio is decoded.

For that 5 minute stereo clip, the finest level, at 256 samples per bucket, has 14,400,000 / 256 = 56,250 buckets per channel. Storing min and max as one signed byte each gives 56,250 x 2 channels x 2 bytes = 225 KB. The coarser levels add a third, so the pyramid is about 300 KB, against 115 MB of decoded float audio: about 0.3 percent. One signed byte per value gives 256 heights, more than a track row has pixels, so Int8Array is enough for display.

Stereo clip at 48 kHz:
A 3 min take at 2048 samples per pixel, 51 seconds on screen. Level used: 1024 samples per bucket. Drawn: 2,400 buckets per channel. Decoded audio would take 69.1 MB; the whole peak pyramid takes 179 KB.2,048 samples per pixelraw samples2048 per px: too slow256 per bucket8.0 buckets per px1024 per bucket2.0 buckets per px4096 per buckettoo coarse: blocky16384 per buckettoo coarse: blockydecoded audio69.1 MBpeaks, all levels179 KBdrawn now2,400 buckets
The renderer picks the 1024 level: the coarsest one still finer than a pixel. It draws 2,400 buckets per channel, whatever the clip length, from peaks that are about 0.3 percent of the decoded size.

Rendering and virtualizing the timeline

  • Map time to pixels with one number. The view keeps secondsPerPixel and scrollSec. A tick converts to seconds with the tempo map, then to x with (sec - scrollSec) / secondsPerPixel.
  • Virtualize in both directions. Only track rows in the viewport are drawn, as in the virtualized list guide. Inside a row, only clips that overlap the visible time range are drawn, found with a binary search over clips sorted by start.
  • Canvas per visible row. Each row is a canvas the width of the viewport. A redraw costs about one min and max line per pixel per row: 1,200 x 16 = about 19,200 lines, which takes a millisecond or two at most on a laptop (an estimate). Clip names, handles and selection outlines can be DOM on top, for hit testing and accessibility.
  • Zoom around the pointer. When the user zooms with Ctrl and the wheel, keep the time under the pointer fixed: scrollSec = timeAtPointer - pointerX * newSecondsPerPixel. Clamp zoom between about 10 samples per pixel and the whole song on one screen.
  • Keep the playhead separate. The playhead moves 60 times a second; draw it on its own layer (a thin absolutely positioned element moved with transform) so the waveform canvases do not repaint every frame.

Check your understanding · Exercise 8

The view is at 2,000 samples per pixel and the pyramid has levels at 256, 1,024, 4,096 and 16,384. What should the renderer do? Select all that apply.

Storing, uploading and exporting audio

Audio is the heaviest data most web apps ever hold. One minute of stereo audio at 48 kHz is 23 MB as 32-bit floats, 17 MB as 24-bit WAV, 11.5 MB as 16-bit WAV, about 5 to 6 MB as FLAC (lossless, an estimate that depends on the music) and under 1 MB as Opus at 128 kbps. Where each form lives decides memory use, load time and upload cost.

Decision In what form should the browser keep audio assets on disk?

Decoded float PCM

Store the Float32Array channels exactly as decodeAudioData returned them.

Pros

  • Instant to load: no decode step

Cons

  • Twice the size of 16-bit WAV and about 24 times the size of a 128 kbps MP3
  • Fills the storage quota fast

The original file plus lossless recordings

Imported files are stored as the user gave them (MP3, WAV, FLAC). Recorded takes are stored as 24-bit PCM chunks. Decode to floats in memory when a clip is needed.

Pros

  • Imports stay byte-identical to what the user gave and to what uploads to the server; takes stay lossless
  • One format to hash for content ids

Cons

  • Opening a project decodes every asset, which takes seconds for large projects
  • Decoded floats still fill memory while open

What we pick: Store originals and 24-bit takes, and decode on demand into a memory cache with a budget (say 1 GB on desktop, much less on phones). Decode clips near the playhead first, and evict decoded buffers far from it. Peaks come from disk, so the timeline draws before any audio is decoded.

Local storage: OPFS, IndexedDB and quotas

  • Project JSON and the edit queue go in IndexedDB, as the indexeddb lesson shows: small records, transactions, indexes by project.
  • Audio assets and takes go in the Origin Private File System (OPFS), reached through navigator.storage.getDirectory(). In a worker, createSyncAccessHandle() gives fast, in-place writes, which suits appending recording chunks. IndexedDB Blobs also work and are the fallback.
  • Quota is large but not unlimited. It varies by browser and free disk space. Read navigator.storage.estimate() before a long recording and warn when less than, say, 10 minutes of recording space is left.
  • Ask for persistence. Call navigator.storage.persist() after the user saves their first project, so the browser does not evict the data under storage pressure. Browsers decide differently whether to grant it; treat the cloud copy as the real backup either way.

Uploading stems

A take is saved locally first, so the user never waits for the network to keep recording. In the background, an upload queue sends each new asset to object storage with a resumable, chunked upload, as the file-uploads lesson and the file uploader guide describe. Before uploading, the client asks the server whether the content hash already exists; a sample from the built-in library or a re-imported file skips the upload entirely. The project JSON can refer to an asset id before its upload finishes; collaborators see a 'still uploading' clip until it does.

Export: rendering faster than real time

Exporting by playing the song and recording the output takes as long as the song and records any glitch. Instead, use an OfflineAudioContext: the same graph API, but it renders into an AudioBuffer as fast as the CPU allows instead of to the speakers.

The export builds a fresh graph from the project, schedules every clip, note and automation point up front (there is no clock to race, so no look-ahead is needed), and calls startRendering(). Load every AudioWorklet module into the offline context too, since modules belong to one context.

ts
async function exportMix(project: Project, assets: AssetCache) {
  const seconds = projectLengthSeconds(project) + 2;   // reverb tail
  const ctx = new OfflineAudioContext({
    numberOfChannels: 2, sampleRate: 48000, length: Math.ceil(seconds * 48000),
  });
  await ctx.audioWorklet.addModule("/worklets/effects.js");
  buildGraph(ctx, project);                // the same code the live engine uses
  scheduleEverything(ctx, project, assets);
  const mix = await ctx.startRendering();  // AudioBuffer, faster than real time
  return encodeInWorker(mix, { format: "wav", bitDepth: 24 });
}

Encoding happens in a worker. WAV is easy in plain JavaScript: a 44-byte header and the samples converted to 16 or 24-bit integers. MP3 needs an encoder compiled to WebAssembly, since browsers do not ship one; Opus can use the WebCodecs AudioEncoder where the browser supports it.

Watch memory: a 4 minute stereo render is a 92 MB AudioBuffer before encoding, because an offline context allocates its whole output up front. For very long projects, render a minute or two at a time in separate offline contexts and stream the encoded chunks into a file. Start each segment a few seconds early so reverbs and compressors settle, and drop that overlap.

The export pipeline. The project JSON and its assets go into an offline audio context, which renders faster than real time into an AudioBuffer of about 92 MB for a 4 minute stereo song. An encoder in a worker turns it into WAV, or MP3 with a WebAssembly encoder, and the user downloads song.wav. The offline graph is built by the same code as the live engine, and worklet modules are loaded again.projectJSON + assetsoffline ctxOfflineAudioContextAudioBuffer92 MB, 4 minencoderworker: WAV, MP3song.wavsame buildGraph() as liveload worklet modules again
No clock to race, so everything is scheduled up front. Memory peaks at the rendered buffer; for long songs, render in segments and stream the encoded chunks into the file.

Check your understanding · Exercise 9

Which choices protect a 30 minute recording session from data loss? Select all that apply.

Architecture, performance, collaboration and mobile

Now put the pieces together. The organising rule: the project store is the only source of truth, and everything else is a view of it. The React UI renders it, the audio engine plays it, the sync layer saves it. Each of those runs at its own pace and on its own thread where it can.

Architecture. On the main thread: UI components send commands to the Project store, which sends changes back to the UI, changes to the Audio engine and operations to the Sync layer. The Asset cache gives decoded buffers to the engine. On the audio thread: the engine controls the node graph, and worklets send meter levels back to the engine. In storage and on the server: IndexedDB and OPFS on the device, and the API and bucket on the server, which the Sync layer talks to. In a worker: the Media worker decodes, computes peaks and encodes, and fills the asset cache.Main threadAudio threadStorage and serverWorkerUItimeline, mixer, rollProject storecommands, undoAudio enginegraph, schedulerSync layerops queue, uploadsAsset cacheNode graphWorkletsIndexedDBOPFSAPI, bucketops, assetsMedia workerdecode, peaks, encodeschedulemeters
The project store is the single source of truth. Each part reads it at its own pace, and nothing that runs every 2.7 ms waits for React.
PartThreadResponsibility
UI (transport, tracks, mixer, piano roll)MainRender the project and selection; turn input into commands; never touch audio nodes directly
Project store and command historyMainApply commands, keep undo and redo stacks, notify subscribers with what changed
Audio engineMain (planning) and audio thread (nodes)Mirror the project as a node graph; run the look-ahead scheduler; expose meters and the clock
Worklet processorsAudio threadMeters, recorder, custom effects; no allocation, no waiting
Media workerWorkerDecode files, compute peaks, encode exports, write takes to OPFS, hash assets
Asset cacheMain plus OPFSDecoded buffers with a memory budget; load and evict by distance from the playhead
Sync layerMain plus IndexedDBQueue edits, send them to the server, apply remote edits, upload assets
Device managerMainMicrophone and MIDI permissions, device lists, latency calibration per device pair

Why the boundary between the store and the engine matters: the engine must never read React state, and React must never hold audio nodes. If a fader drag re-rendered the whole track list, or the engine waited on a render, we would bring main thread jank straight back into the sound. The engine subscribes to the store, receives small change records such as 'clip c7 moved' or 'track t3 volume -4 dB', and applies them to its own graph.

Fast-moving values follow the opposite direction without the store. Meter levels arrive from worklets about 20 to 60 times a second and are drawn straight to a canvas in a requestAnimationFrame loop. A fader being dragged updates its AudioParam at once and commits one command to the store when the drag ends, so undo gets one step, not 200.

Data flow. The API server sends remote ops to the Sync layer, which applies them to the Project store; the store renders changes in the UI. The UI sends commands to the store, the store sends local ops to the Sync layer, and the Sync layer posts them to the API. Audio bytes come from object storage to the Media worker, which decodes them into the Asset cache, which hands buffers to the Audio engine; the store sends changes to the engine too. New takes are uploaded in chunks from the Sync layer to object storage. Meters and the playhead go from the engine to the UI every frame, outside the store.APIops, versionsSync layerqueue in IDBStoreprojectUIReactbucketobject storageMedia workerdecode, peaksAsset cachedecodedEngineto speakersremote opsPOST opsapplylocal opsrendercommandchangesbytesfloatsuploadmeters and playhead: engine to UI every frame, outside the store
Two kinds of traffic: small operations that flow through the store, and big audio bytes that never touch it. The store only holds asset ids.

Interfaces

ts
interface AudioEngine {
  unlock(): Promise<void>;                 // ctx.resume() from a user gesture
  load(project: Project): Promise<void>;   // build graph, decode near clips
  apply(changes: ProjectChange[]): void;   // from the store, after each command
  play(fromTick: Tick): void;
  stop(): void;
  setLoop(range: { start: Tick; end: Tick } | null): void;
  armRecord(trackId: string, deviceId: string): Promise<void>;
  position(): { tick: Tick; seconds: number };   // read in rAF; latency corrected
  meters: EventTarget;                     // "levels" events, about 30 per second
  freeze(trackId: string): Promise<string>;      // returns the new assetId
}
The engine's surface, as the UI and store see it.
http
GET  /projects/:id                -> { project, version, assets: [{ id, url, peaksUrl }] }
POST /projects/:id/ops             { baseVersion, ops: [...] } -> { version } | 409 { missing ops }
WS   /projects/:id/live            server -> { type: "ops", version, ops } | { type: "presence", ... }
HEAD /assets/:sha256               -> 200 exists | 404 upload needed
POST /assets                       { sha256, bytes, mime } -> { uploadUrl, chunkSize }
PUT  {uploadUrl}  Content-Range: bytes 0-8388607/52428800   (resumable chunks)
Server endpoints. Edits are small operations; assets go straight to object storage.

Think first

Why does the engine take a list of changes rather than the whole new project after each edit?

The audio CPU budget and freezing tracks

Every node in the live graph costs time on the audio thread, every 2.67 ms. Native gains and panners are almost free. A ConvolverNode reverb with a 3 second impulse response is one of the most expensive nodes; a dozen of them can use a laptop's whole budget (an estimate; it depends on the machine). So reverbs live on a shared bus that tracks send to, not as an insert on every track.

Browsers do not offer a standard audio CPU meter, so estimate it yourself. On load, render 5 seconds of the project in an OfflineAudioContext and time it: if 5 seconds of music take 2 seconds to render, the graph needs about 40% of real time on this machine. It is a rough proxy, since the offline render does not run on the real-time audio thread, so show it as a CPU meter and warn early, above about 70%. The runtime-performance lesson covers keeping the main thread free, which protects the scheduler.

When the budget runs out, freeze a track: render it with its instrument and effects through an OfflineAudioContext into a new asset, then play that one buffer instead of the whole chain. A synth with 4 effects becomes one source node. The project keeps the original clips and sets frozenAssetId; unfreezing throws the render away. Desktop DAWs such as Ableton Live and Logic Pro offer the same freeze feature.

Collaboration, and why not live jamming

Collaborative editing works like the Google Docs and Figma guides: each command becomes a small operation ('move clip c7 to tick 15,360') sent to the server with the version it was based on, and remote operations are applied to the local store and then to the engine.

Most edits touch different clips, so a per-object last-writer-wins rule resolves the rare conflicts. A CRDT library (a data type that merges concurrent edits on its own, as the crdts lesson explains) is the heavier alternative. Audio assets are uploaded once and referenced by hash, so a collaborator downloads a take only when they open the project.

Playing music together live over the internet is a different problem. In clapping experiments at Stanford, pairs of players slowed down more and more as the delay between them grew, and about 25 ms is the limit usually quoted for playing comfortably together (sound in air travels about 8 m in that time). Light in optical fibre covers about 200 km per ms, so London to New York, about 5,600 km in a straight line, costs about 28 ms one way before any router, jitter buffer or audio buffer.

Add 10 to 30 ms of audio input and output latency at each end, and the total is several times over the limit. That is why browser studios like these sync edits, not live sound.

Accessibility

  • Transport by keyboard. Space plays and stops, R records, L toggles the loop, Home returns to the start. Shortcuts are listed in a help dialog and do not fire while focus is in a text field, as the keyboard-navigation lesson explains.
  • Faders and knobs are sliders. Each uses role="slider" (or a native range input) with aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext such as "-6 decibels" or "pan 30 percent left", because a raw gain of 0.5 means nothing to a listener. Arrow keys change by 0.5 dB, Page Up and Page Down by 6 dB. See the aria-patterns lesson.
  • The track list is real DOM. Track headers are a list of groups, each with a labelled name, mute, solo and arm buttons using aria-pressed.
  • A text alternative for the canvas. The timeline canvas is invisible to screen readers, so offer a clip list view: a table of clips per track with start bar, length and name, where clips can be moved by editing numbers.
  • Announce state, not levels. A polite live region says "Recording, track 3" or "Input clipping"; meters are not announced. Respect reduced motion for decorative animation, but the playhead stays, since it carries meaning.

Mobile limits

Phones are much tighter. Memory per tab is smaller, so the decoded audio budget must drop to a few hundred MB or less. And there is no Web MIDI on iOS at all.

On iOS, Web Audio is muted by the ring and silent switch by default. Recent Safari versions add the Audio Session API: setting navigator.audioSession.type = "playback" before playing marks the page as a music app and lets sound play with the switch on. The API is still Safari only and behaviour has changed between releases, so feature detect it and test on real devices. Safari can also put the context into an interrupted state during a phone call, so listen for statechange and show a tap-to-resume button.

Check your understanding · Exercise 10

A user's 22 track project crackles during playback on an older laptop. Which steps help? Select all that apply.

Running it in the interview

This question is huge, and the trap is spending 20 minutes on the mixer UI. Interviewers want to see that you know where the real-time parts are. A 60 minute plan:

  1. Minutes 0 to 7: scope. Ask about audio versus MIDI, recording, project size, collaboration and target browsers. Say early that live jamming is out of scope and why, in one sentence.
  2. Minutes 7 to 12: the two clocks. Name the core difficulty before drawing boxes: the main thread is unreliable, the audio clock is exact, and the audio thread has a 2.7 ms deadline.
  3. Minutes 12 to 25: architecture. Draw the project store, UI, audio engine, worklets, media worker, asset cache and sync layer. Then the audio graph: sources, channel strips, buses, master.
  4. Minutes 25 to 40: two deep dives. The look-ahead scheduler with tick-to-seconds conversion, and recording with latency compensation. Let the interviewer pick if they have a preference.
  5. Minutes 40 to 52: data and storage. The project JSON with clips pointing at assets, peaks for waveforms, OPFS and uploads, export with an OfflineAudioContext.
  6. Minutes 52 to 60: budgets and wrap-up. CPU budget and freezing, accessibility of faders, mobile limits, and what you would build first.

Say the numbers out loud. '128 frames at 48 kHz is 2.67 ms', '25 ms timer, 100 ms look-ahead', '23 MB per stereo minute decoded', '256 samples per peak bucket'. Numbers show you have reasoned about the system rather than recited a component list.

Follow-up questions to expect

Think first

The user changes the tempo while the song is playing. What happens in your engine?

Think first

A project has 60 minutes of audio across 40 tracks. How do you open it on an 8 GB laptop?

Think first

Your company embeds the studio in partner sites and cannot set COOP and COEP headers. What changes?

Summary

  • There are two clocks. The main thread is late whenever it is busy; the audio clock is exact. Plan notes on the audio clock with a look-ahead scheduler (wake every 25 ms, schedule 100 ms ahead) and never fire them from timers.
  • Build sound as a Web Audio graph: one-shot source nodes playing shared decoded buffers, a channel strip per track, buses and a master. Ramp every parameter change to avoid clicks, and resume the context from a user gesture.
  • The audio thread has a 128-frame deadline, about 2.7 ms at 48 kHz. AudioWorklet code must not allocate, block or wait; stream samples through a SharedArrayBuffer ring buffer, which needs COOP and COEP headers.
  • Recorded takes arrive late by the round-trip latency. Capture raw PCM with call-style processing turned off, stamp it with the audio clock, measure latency with a loopback test per device pair, and shift the clip.
  • The project is a small JSON document of tracks, clips in ticks, notes and automation, pointing at immutable, content-addressed audio assets. Edits change numbers, never audio.
  • Draw waveforms from a pyramid of min and max peaks computed in a worker: about 0.3 percent of the decoded size, and one to a few buckets per pixel at any zoom.
  • Audio is heavy: 23 MB per decoded stereo minute. Keep originals and takes in OPFS, decode on demand with a memory budget, upload once by hash, and export with an OfflineAudioContext faster than real time.
  • Budget the audio thread like a frame budget: share reverbs on buses, freeze heavy tracks to audio, and offer a safer latency setting on slow machines.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next