How Bandroom is built

Bandroom is a small, embeddable music tool for primary classrooms: a synthesised keyboard (with drum kit and guitar previews), session recording with layers, a text sequence log, MP3 export, a metronome with count-in, and challenges a host platform can send in and get results back from. Every sound is made with the Web Audio API at the moment it is played. There are no audio samples.

The decisions below are written for the lead developer who would own it.

Map of the code

Folder What lives there
src/core Tuning (12-TET, any A4), note names, fixed-do solfege, drum names on General MIDI numbers, seeded random numbers
src/engine The synthesis engine: context lifecycle, master bus, polyphony allocator, envelopes, clocks, offline renderer, capture tape
src/instruments The instrument contract, the registry, and one folder per instrument (keyboard, drums, guitar) with its patch (sound) and view (UI)
src/session Recorder, lookahead player, metronome, the text log format, simple notation, challenge checker
src/export Offline render to MP3 (worker + wasm) with a WAV fallback
src/bridge Bridge protocol v1 (types and guards), the tool side, and a host-side client helper
src/ui App shell, transport, log panel, challenge banner, settings, skins, note bubbles, debug overlay
public/skins Two hand-drawn SVG skins (graphics are placeholders, replaceable without code)
public/challenges.json Example challenges, in the same format a backend would send

Synthesis engine

Engine takes any BaseAudioContext. The same code plays live on an AudioContext, renders the MP3 on an OfflineAudioContext, and renders the walkthrough video's sound track.

Signal path

voice -> instrument bus (guitar: body EQ) -> mix -> soft clip -> volume -> speakers

Polyphony

The allocator is a pure, time-aware function: admit(time, group). It runs three steps, in order:

  1. Forget voices that are already silent.
  2. Cut any voice in the same group (a re-struck key, the same guitar string, closed hi-hat over open).
  3. While at the cap, steal the quietest released voice, else the oldest one.

Because it works on audio times rather than wall time, live play, scheduled playback and the offline export all make identical decisions. Notes that playback has booked but not yet started count against the cap but are never cut or stolen by a live press, so playing along with a take never silences it.

Voice caps per performance tier

Tier Keyboard Drums Guitar Visual effects
low (auto on ≤ 4 cores or ≤ 4 GB) 8 8 6 8 bubbles, 1x canvas
standard 12 12 6 24 bubbles
high (opt-in) 20 16 6 48 bubbles

Ending a voice without a click

Clocks

Instruments

An instrument is one folder that implements InstrumentModule (src/instruments/types.ts):

The recorder, log, export, bridge and challenges work with any instrument unchanged.

Keyboard (88 keys, piano-like)

Drum kit (preview)

Guitar (preview)

Accuracy

Accuracy is measured in Chrome on offline renders of the real engine (e2e/accuracy.spec.ts), using two steps:

  1. YIN confirms the octave.
  2. The peak of a Hann-windowed, zero-padded FFT, refined by log-parabolic interpolation, gives the pitch.

The analyser itself is checked on synthetic tones to better than 0.05 cent.

What Measured Tolerance Worst error
Keyboard, all 88 keys, low and standard tiers, A4 = 440 176 notes ±1 cent 0.004 cent
Keyboard at A4 = 432 and 442 26 notes ±1 cent 0.001 cent
Guitar, 6 strings × frets 0–12, at 44.1 and 48 kHz 156 notes ±2 cents 0.07 cent
Drum toms (A2, D3, G3) 3 ±5 cents 0.001 cent
Kick drum after its pitch fall 1 50 Hz ±3 % 50.00 Hz

Other checks:

Recording, playback and the metronome

Recording

Playback

Metronome

Text sequence log

The log is human-readable, copyable, and parses back exactly (serialize(parse(text)) === text for canonical text, fuzz-tested on 1,000 random sessions):

# music-tool log v1
# instrument: keyboard
# a4: 440
# bpm: 90
# notes: 3
# length: 1.402
# time   instrument  note  length  velocity  detail
0.000    keyboard    C4    0.398   100
0.502    keyboard    E4    0.400   100
1.002    guitar      G3    0.400   96    s6f3

MP3 export

  1. The same engine renders the take on an OfflineAudioContext (44.1 kHz, mono), in one-second chunks with progress.
  2. A Web Worker encodes it with LAME (WebAssembly) at CBR 128 kbps, which is standard MPEG-1 and about 1 MB a minute.

Checked in tests:

Speed: a 60 s take renders in about 0.8 s on a desktop; see the performance table for throttled figures.

Fallbacks: the main thread takes over if a worker is blocked, and WAV is used if WebAssembly is blocked by a strict Content Security Policy. Either way the host is told through the bridge.

Licence: LAME is LGPL. The wasm file is shipped unmodified as a separate file, and its URL can be overridden. The rest of the code is original to this project.

Bridge

The tool runs in an iframe, embed.html?host=<your origin>, and talks to the host page with versioned envelopes:

{ protocol: "music-tool", v: 1, type, id?, re?, payload }
Direction Messages
tool → host ready, state, log, exported (with the file as a Blob), challenge:progress, challenge:result, resize, ack, error, note (opt-in)
host → tool config, challenge:load, challenge:clear, command (record, stop, play, clear, export, getLog), log:load

Rules

src/bridge/client.ts is a small host-side helper:

const tool = connectMusicTool(iframe, "https://tools.example.org");
tool.on("challenge:result", (result) => save(result));
await tool.ready;
await tool.send("challenge:load", challenge);

Challenges

A challenge is plain JSON:

{ "id": "twinkle-1", "v": 1, "kind": "sequence", "instrument": "keyboard",
  "title": "Twinkle, line 1", "prompt": "Play the first line", "target": "C C G G A A G",
  "hints": "next" }

Custom element

<music-tool instrument="keyboard" labels="solfege"> from music-tool.js gives the same API as methods and DOM events (ready, loadChallenge(), command(), getLog(), challenge:result, …), inside Shadow DOM so styles never clash.

Graphics and skins

Every graphic is a named slot (src/instruments/skin-slots.json):

A skin folder maps slots to files and can override colour tokens. Swapping art is a new folder, with no code change. SKINS.md, generated from the slot list, gives illustrators the size and purpose of every slot.

The two skins here (Bright and Chalkboard) are hand-written SVG placeholders, made without AI image tools.

Performance on school Chromebooks

Engineering choices

Measurements

Measured with scripts/perf.mjs, in Chrome with CPU throttling. This is emulation: no real Chromebook was available, and acoustic output latency (ChromeOS audio, Bluetooth) is outside what a page can measure. Open the tool with ?debug=1 on a school Chromebook to see the real figures.

Desktop Chromebook-like, CPU 4× slower CPU 6× slower
Tap → note handed to the engine, p50 / p95 1.2 / 1.9 ms 38 / 45.7 ms 40.6 / 54.4 ms
Same taps on an empty page (browser and emulation alone) 0.4 / 0.6 ms 29 / 35.2 ms 31.1 / 40.5 ms
The tool's own press handler, p50 / p95 0.4 / 0.6 ms 1.4 / 2.6 ms 2 / 5.2 ms
Slide onto the next key, p50 / p95 0.7 / 1.1 ms 40.4 / 45 ms 44.2 / 63.3 ms
Long tasks while playing 0 0 3 (longest 55 ms)
Frame interval, p95 16.7 ms 16.7 ms 16.8 ms
Ready to play after load 0.23 s 0.63 s 0.95 s
60 s take: whole Save (render, MP3, download) 1.6 s 1.9 s 2.9 s

Under emulation most of the wait happens before the page sees the input: an empty page with one listener waits 29 ms at 4×. The tool adds its handler (1.4 ms) and a few milliseconds of rendering from the previous press. The MP3 encoder runs in a worker, which Chrome's CPU emulation does not slow down, so the throttled Save figures are optimistic. Chrome reported an output latency of 10 + 32 ms on this machine.

Adding an instrument

  1. Create src/instruments/<id>/patch.ts that exports a Patch: a voice() that builds a few nodes on any context and returns a BasicVoice, plus caps per tier.
  2. Create a view that calls host.press() and host.lift() with { note, vel, detail? } and implements show(), notePoint() and key().
  3. Add its slots to skin-slots.json and an entry to INSTRUMENTS in registry.ts.
  4. Add the patch to PATCHES so offline renders can play it.
  5. Add accuracy cases to e2e/accuracy.spec.ts.

Tests

Known limits