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.
- Stack: TypeScript (strict), Vite, no UI framework. The only runtime dependency is the LAME MP3 encoder compiled to WebAssembly, which loads only when someone saves an MP3.
- Size: about 34 kB gzipped before the tool is ready to play, including styles. Drum kit and guitar load on first use (about 2–3 kB each).
- Embedding: an iframe with a versioned
postMessagebridge (recommended), or a<music-tool>custom element for same-origin pages. - Browsers: built and tested in Chrome, which is what Chromebooks run. Firefox and Safari paths exist (noted below) but are not certified.
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
- The soft clip is a WaveShaper that is exactly linear below 0.7, so normal playing passes through untouched.
- A
DynamicsCompressorNodewas measured and rejected: it delays every note by about 6 ms. The soft clip adds no delay.
Polyphony
The allocator is a pure, time-aware function: admit(time, group). It runs three steps, in order:
- Forget voices that are already silent.
- Cut any voice in the same group (a re-struck key, the same guitar string, closed hi-hat over open).
- 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
- Every steal, choke and Stop holds the envelope's current value (
cancelAndHoldAtTime), fades it with a 5 ms time constant, and stops the sources 40 ms later. - Envelopes are declared once. The same declaration schedules the
AudioParamand gives an analyticlevel(t). The allocator useslevel(t)to choose a quiet voice, and Firefox (which has nocancelAndHoldAtTime) uses it to hold the exact value.
Clocks
LiveClockmaps an input event'stimeStamponto the audible timeline withgetOutputTimestamp().SteppedClockadvances one video frame at a time, for frame-exact recordings.- UI timing goes through the clock interface, so the same code runs in both modes.
Instruments
An instrument is one folder that implements InstrumentModule (src/instruments/types.ts):
- a
Patchthat makes sound on any context - a
mount()that builds its view - labels, its range and its skin slots
The recorder, log, export, bridge and challenges work with any instrument unchanged.
Keyboard (88 keys, piano-like)
- Wave tables: each octave has two in-phase wave tables, "bright" and "mellow". A note starts bright, then its brightness fades into the mellow body.
- Why no filter sweep: a lowpass filter sweeping during the note bends the phase of the fundamental and pulls the pitch flat by up to a cent. This was measured, which is why the patch has no filter.
- Envelope: two-stage decay, like a struck string; key up drops the damper.
- Nodes: 4 per voice on the low tier. Standard adds a symmetric ±0.12 Hz unison and a 30 ms seeded hammer noise.
Drum kit (preview)
- Sounds: nine pads made from oscillators and one shared, seeded noise buffer. The pads are kick, snare, clap, closed and open hi-hat (closed chokes open), crash, and three toms tuned to A2, D3 and G3.
- Renders: each hit reads the noise from a repeatable offset, so renders match run to run.
Guitar (preview)
- String model: Karplus-Strong with a fractional-delay allpass. Its coefficient gives exactly the needed phase delay at the fundamental, so each string sounds at its target pitch. A whole-sample delay line would leave strings several cents out of tune.
- Rendering: strings are rendered once per sample rate into 12 buffers (6 strings × frets 0 and 7) and played with
playbackRate, always within ±5 semitones of a buffer. - Why not an AudioWorklet: JavaScript on the audio thread is a glitch risk on Chromebooks, and every offline render would need an async module load. The
Patch.prepare()hook is already async, so a worklet patch (for bends and slides) can be added later behind the same contract.
Accuracy
Accuracy is measured in Chrome on offline renders of the real engine (e2e/accuracy.spec.ts), using two steps:
- YIN confirms the octave.
- 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:
- Unpitched drums are checked by spectrum and decay instead.
- Voice caps are never exceeded.
- Ending a voice adds no click. The click measure is checked against a hard stop, which does click.
- Two renders of the same session agree to within float rounding (below −120 dBFS).
Recording, playback and the metronome
Recording
- Times come from the input event's
timeStamp, mapped onto the audible timeline. A busy main thread on a slow Chromebook therefore does not smear the child's rhythm. - Notes are stored relative to the start of the take, on a 1 ms grid.
- Record on an existing take adds a layer: the take plays back while the new part is recorded.
Playback
- Playback books notes on the audio clock 0.5 s ahead, every 50 ms ("A Tale of Two Clocks"). Stop cancels the booked notes.
- Keys and log lines light up when the note is audible (
clock.heard()), not when it was booked, so highlights stay with the sound.
Metronome
- The metronome uses the same scheduler. With the metronome on, Record counts in one bar, and the bpm goes into the log.
- Clicks are a separate patch and are never recorded or exported.
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
- Fields: each line is
time instrument note length velocity [detail], separated by whitespace. - Notes:
C4,F#3,Bb2(octave −1..9), drum names (kick,snare,hat, …), orm<0..127>. - Header: unknown
# key: valuelines are kept. A v2 log is refused with a clear message. Every error carries a line number. - Simple view: pupils and teachers see
C C G G A A G, with chords grouped as[C E G]and one line per phrase. The same notation is the challenge target language.
MP3 export
- The same engine renders the take on an
OfflineAudioContext(44.1 kHz, mono), in one-second chunks with progress. - 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:
- every frame header
- the decoded length against the render
- the correlation with a fresh render (0.9998)
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
- Origins: the tool accepts messages only from its parent window and only from origins listed in
?host=. It replies only to that origin, never*. - Replies: a request with an
idgets anackor anerrorwithre, so a host can await it. - Versioning: new fields and types keep v1; a breaking change becomes v2, with v1 still answered for at least one release.
- Validation: inputs are checked with hand-written guards.
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" }
- Kinds:
echochallenges play the target first. - Options:
octave: "exact",chordWindowMs,lockInstrumentandmaxSecondsare optional. - Scoring: a wrong note counts as a mistake but keeps the place, which suits KS1. The score is steps / (steps + mistakes), with up to 3 stars.
- Re-checking: the result includes the attempt as a v1 log. The checker is a pure function with no browser dependencies, so a backend can re-run it on that log instead of trusting the client. The example host page does exactly that.
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):
- the logo and backdrop
- white and black keys and the keyboard body
- drum pads
- the guitar's neck and body
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
- Pointer events with pointer capture and
touch-action: none. - The press handler starts the note first and does no layout reads; hit-testing is arithmetic on the key layout.
- No lookahead for live notes.
- Animations use transform and opacity only, with one canvas whose loop runs only while bubbles are alive.
- Instruments and the encoder load lazily. Reduced motion is respected, with a switch in Settings.
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
- Create
src/instruments/<id>/patch.tsthat exports aPatch: avoice()that builds a few nodes on any context and returns aBasicVoice, plus caps per tier. - Create a view that calls
host.press()andhost.lift()with{ note, vel, detail? }and implementsshow(),notePoint()andkey(). - Add its slots to
skin-slots.jsonand an entry toINSTRUMENTSinregistry.ts. - Add the patch to
PATCHESso offline renders can play it. - Add accuracy cases to
e2e/accuracy.spec.ts.
Tests
pnpm test(vitest, Node): tuning, the log format with fuzzing, simple notation, the challenge checker, recorder, player, metronome, allocator, envelopes, Karplus-Strong tuning, and the analyser's self-test.pnpm e2e(Playwright, Chrome):- accuracy, polyphony and clicks
- MP3 export
- every input path on desktop, a touch Chromebook-sized screen and a 375 px phone
- recording, layers, playback, the count-in, the log, settings and skins, reduced motion
- the host page round trip, the origin allow-list, and the custom element
- 34 regression tests from a separate code-review pass (31 in
e2e/review/, 3 intests/review-regressions.test.ts), each of which failed before its fix
pnpm verify: typecheck, unit tests, build, bundle budgets, and a scan for contact details.
Known limits
- Only Chrome is tested.
- Firefox uses the envelope-model fallback.
- Safari's
interruptedstate shows the "Tap to start" screen.
- Touch was tested with Chrome's touch emulation, not on a physical device.
- The drum kit and guitar are previews: one sound per pad, and seven chord shapes.
- No MIDI input, no audio input, and no accounts. Sessions live in the page until saved or sent to the host.