Engineering

Rhythm Brown Box, Rebuilt

The unit

The box, and why it still runs

RhythmBrownBox started life as a portfolio deliverable back in my freeCodeCamp days. The brief in my head was small: a step sequencer that runs in a browser tab, four sampled drum hits, an 8-step grid, a volume knob and a speed knob. index.html, main.js, main.css. No build step, no framework, no package manning the whole thing.

What the brief didn’t say, and what actually kept the project alive, is that I’m a guitar player. RhythmBrownBox became the thing I opened when I wanted a pattern to practice against — better than a bare metronome, worse than a real drummer, and always exactly one tab away. Eleven commits, MPL-2.0, and it had been sitting on my portfolio page unchanged for a long time.

This is the write-up of taking it apart and putting it back together as v2.

Complaints

Living with version one

The tell that a small project is worth a rebuild is that you’ve used it enough to have complaints. Mine were specific:

  • The speed knob was backwards and unitless. It was labelled “Interval-second” and set the gap between steps directly, in seconds, so turning it up made the beat slower. Nobody thinks in “0.30 seconds per step.” Guitarists think in BPM.
  • A refresh wiped the pattern. Every time. Close the tab mid-practice and the groove was gone.
  • Stop didn’t stop. There is a stopPlay() function in main.js and it is completely empty; its button is commented out in the markup. The transport could start and never cleanly stop.
  • The beat drifted if I looked away. Switch to another tab to pull up a chord chart, come back, and the timing had wandered.

None of these made it unusable. All of them made it feel like a demo rather than a tool. That’s the gap v2 set out to close.

01 · audit

Reading it back

Before touching anything I read the whole thing again with fresh eyes and wrote down what was actually in the box in August 2026 — not what I remembered building.

Spec sheet, as found

markup / logic
index.html (46 lines), main.js (~220), main.css (91) — no build step, no modules
audio
Web Audio API, 4 sampled hits, lookahead scheduler on setInterval
UI widgets
NexusUI, vendored as a 152 KB minified bundle with no version marker
chrome
jQuery, Bootstrap 3.3.7, Font Awesome 4.4, devicon, Google Maps — all via CDN, none used
tests
none — no package.json, no runner, no CI
history
11 commits; the last feature commit added Mute, everything since is README fixes

The markup

index.html pulls in jQuery, Bootstrap 3.3.7 (CSS and JS), Font Awesome 4.4, devicon from RawGit, and a Google Maps script with no API key. Not one of those is used by the app. The Maps script is a guaranteed console error on every load, and RawGit shut down in 2019, so the devicon request is a hard 404 now. The only genuinely load-bearing external dependency is NexusUI — vendored as a 152 KB minified blob with no version marker anywhere in it.

The CSS

Three real bugs, all silent:

  • A rule written for .headear that was always meant to style .header. The page title had been rendering with browser defaults since day one.
  • width: 100x; on the buttons — 100x is not a unit, so the browser throws the whole declaration away.
  • The bottom @media block is missing its closing brace. Browsers auto-close an unterminated block at end-of-file and carry on, which is exactly why this survived eleven commits without anyone noticing.

The JavaScript

Everything is a bare var in global scope — seq, step, interval, matrix. The kick samples are loaded into misnamed variables (kick1.wav ends up in a variable called kick2 and vice versa); it sounds fine because both are just “kick,” but the code lies to the next reader. Samples load through XMLHttpRequest with the old callback-style decodeAudioData.

And the part that matters most: the scheduler. v1 already has the right shape — a timer that wakes up periodically and schedules drum hits a little way into the future rather than triggering them inline. That’s the correct instinct for Web Audio. But it runs on a plain setInterval on the main thread, and browsers throttle main-thread timers hard once a tab is backgrounded — clamped to no more than one tick per second. That is the drift I kept hearing.

The honest conclusion wasn’t a bug list. It was that v1 had no way to catch its own bugs. Fix that first.

No package.json, no test runner, no CI, and every piece of logic welded to a live DOM and a live AudioContext so you couldn’t test any of it without clicking buttons in a browser.

02 · method

Picking a method, and paying for it

For a one-person portfolio project it would be easy to over-ceremony this. I didn’t want stand-ups with myself. What I wanted was three specific disciplines, each earning its place.

Agile, kept to a single list. One ordered backlog, worked in short passes, and a hard rule that every pass ends with the app playing a beat end to end — no half-refactored file left overnight. The cost is that you can’t do a big-bang rewrite; the benefit is you’re never more than one small step from a working program.

BDD for anything the user sees. Every behaviour the audit found broken — the pattern firing on the right beat, mute actually muting, stop actually stopping — got written first as a plain-language Given/When/Then scenario, in a .feature file, before any code moved. The downside is real: these read like Cucumber specs but I’m not running a Gherkin step-runner in CI, so they’re executable-style documentation rather than literally executed. For a project this size, the value was in forcing me to state the expected behaviour before changing it.

TDD for the logic underneath. The scheduler math, the grid resize, the tempo conversion — none of that actually needs the DOM or Web Audio. It’s functions over numbers and arrays. Pull each one out, write a failing test, write just enough to pass, refactor. That’s the loop v1 never had.

03 · side a

Making it testable

Tooling before features. npm project, Vite for the dev server and production build, Vitest for tests, ESLint + Prettier, and a GitHub Actions workflow that runs lint, tests and a build on every push.

Then the correctness bugs from the audit, in one pass covered by scenarios: the .headear typo, the 100x unit, the swapped kick mapping, the dead CDN includes all deleted.

One thing the audit missed showed up the instant Vite tried to bundle the CSS. Vite runs stylesheets through PostCSS — a real parser — and it refused the unterminated @media block outright. Browsers had been quietly forgiving that for years; the build tool wasn’t. One added } and it passed. Worth noting because you can’t find that class of bug by reading — only by trying to build.

Worked example: locking the clock

The scenario first:

scenario · features/playback.feature
Feature: Step sequencer playback

  Scenario: A toggled step triggers its sample on the correct beat
    Given the matrix has a hi-hat step armed at column 2
    And the transport is stopped at step 0
    When the transport advances to column 2
    Then the hi-hat sample is triggered exactly once
    And no other sample is triggered on that beat

Then the failing test, against a scheduler.js that doesn’t exist yet:

test · src/scheduler.test.js — red
import { stepsToFire } from './scheduler.js';

it('schedules every step between now and the lookahead window', () => {
  const events = stepsToFire({ now: 0, lookahead: 0.375, interval: 0.125, fromStep: 0 });
  expect(events.map((e) => e.step)).toEqual([1, 2, 3]);
});

Then the smallest code that turns it green — lift the while loop out of v1’s setInterval callback into a pure function with no DOM and no AudioContext inside it:

src/scheduler.js — green
export function stepsToFire({ now, lookahead, interval, fromStep }) {
  const events = [];
  let step = fromStep;
  let time = now;
  const scheduleEnd = now + lookahead;
  while (time < scheduleEnd) {
    step += 1;
    events.push({ step, time });
    time += interval;
  }
  return events;
}

Four tests cover it now — the normal window, an empty window, a custom step offset, interval spacing. Once it was green, the timing source moved off the main thread and into a Web Worker (transport-worker.js) that does nothing but postMessage({ type: 'tick' }) on an interval. Workers aren’t subject to background-tab throttling, so the drift I’d been living with is gone.

Why two clocks

setTimeout and setInterval are not accurate enough to sequence music — they’re subject to event-loop congestion and get throttled in background tabs. But AudioContext.currentTime and AudioBufferSourceNode.start(when) are sample-accurate: tell the audio thread to play a sound at currentTime + 0.2 and it plays exactly then, regardless of what the main thread is doing.

So you run two clocks. A coarse JavaScript timer wakes every ~250 ms and asks “which beats fall in the next ~375 ms?”, schedules those on the precise audio clock, and goes back to sleep. As long as the lookahead window is wider than the timer’s jitter, the beat never misses. This pattern is Chris Wilson’s “A Tale of Two Clocks” (2013); the modern refinement is exactly what Side A did — put the wake-up timer in a Worker so a hidden tab can’t starve it.

3commits
15unit tests
0external deps
~6 KBjs bundle · was 152 KB

04 · side b

The parts a musician asked for

Real tempo

The speed knob is now a BPM control, 40–240, with the conversion to a step interval pulled into its own tested module:

src/tempo.js
// A step is an eighth note -- two steps per quarter-note beat --
// matching the original 8-step, one-bar-of-4/4 grid.
const STEPS_PER_BEAT = 2;

export function bpmToStepInterval(bpm, stepsPerBeat = STEPS_PER_BEAT) {
  return 60 / bpm / stepsPerBeat;
}

The music theory here is small but it has to be right. In 4/4 time there are four quarter-note beats to a bar. An 8-step grid across one bar means each step is an eighth note — two steps per beat. Seconds per beat is 60 / bpm; divide by steps-per-beat and you get seconds per step. At 120 BPM that’s 60 / 120 / 2 = 0.25 s, which is the number v1 had hard-coded as a magic default without ever saying why. Making stepsPerBeat a parameter means 16-step patterns are sixteenth notes and the same function still holds.

Bigger kit

Step count is now 8, 16 or 32 instead of a fixed 8, with a resize that keeps existing steps when you grow the grid and truncates when you shrink it. Track count stayed at 4 — there’s no fifth sample, so a fifth row wouldn’t mean anything.

A subtlety I gave up on purpose

v1’s playback loop indexes each row independently: seq[i][step % seq[i].length]. If the rows had been different lengths, you’d have got polymeter — a 3-against-4 feel, the kind of thing Afrobeat and a lot of math rock lives on. In practice every row was 8, so it never happened, but the structure allowed it. v2 unifies every row to one step count, which makes the UI predictable and the save format simple, and closes that door. For a practice tool that’s the right call; I noted it as a possible future “advanced mode” and moved on.

Save the take

The pattern now autosaves to localStorage on every edit and survives a refresh. The Share button serialises the grid, tempo and step count, base64s it into a URL query param, and copies that link to the clipboard via the navigator.clipboard API — with a window.prompt fallback for when clipboard access is denied. Open someone’s share link and that pattern becomes your saved pattern from then on.

Finish the stop button

A real stop that terminates the Worker and resets the step counter, verified by a start / stop / restart scenario that a stopped-then-restarted transport resumes cleanly from step zero.

05 · side c

Feel, access, and the NexusUI question

v1’s dials and grid are NexusUI canvas widgets. NexusUI is a genuinely nice library — Ben Taylor and collaborators built it specifically for web-audio interfaces, and its dials, sliders and matrix look and feel like hardware. But everything it draws is pixels on a <canvas>. There is no DOM node for a screen reader to announce, no role, no tabindex, no keyboard path at all. If you can’t use a mouse, v1’s interface does not exist.

The roadmap left NexusUI’s fate open — “spike, then decide.” In practice there were two honest options:

  1. Keep the canvas widgets, add a parallel accessible layer. Hidden ARIA controls mirrored to the canvas state — two implementations of every interaction to keep in sync forever.
  2. Replace them with native controls. <input type="range"> for the dials, a grid of real <button> elements for the steps.

Option 2 won easily. Native form controls get focus, Tab order, Space/Enter activation, and correct name/role/state reported to the accessibility tree for free — the browser does it. A range input responds to arrow keys with no code. I added arrow-key navigation between grid cells on top of the normal Tab order, and every cell announces as “Hi-hat, step 3” to a screen reader. Choosing native also collapsed two backlog items into one move and let me delete the vendored 152 KB NexusUI bundle entirely.

The v2 interface: a Volume and a Tempo (BPM) slider, a pattern-length dropdown set to 8 steps, a 4-by-8 grid of empty step buttons for hi-hat, rim and two kicks, and a row of Reset / Mute / Random / Start / Stop / Share buttons, all on the peru-brown panel.
v2 in Chrome — NexusUI’s canvas dials and matrix replaced by native range sliders, a real <select>, and a grid of <button> cells. The peru-and-brown identity is kept; the widgets underneath are now things a keyboard and a screen reader can reach.

Once NexusUI was gone, Bootstrap’s grid classes were the only thing left being pulled from a CDN, and the layout is a few flexbox rules’ worth of complexity. That went too. The finished app loads no external scripts or stylesheets at all — the entire JavaScript bundle is about 6 KB, down from 152 KB of vendored library plus jQuery plus Bootstrap.

The cost is aesthetic: native range inputs and buttons don’t look like hardware the way NexusUI’s dials do. I styled them back toward the peru-and-brown identity with CSS, but a purist would miss the skeuomorphic knobs. For a tool whose whole point is that anyone can pick up a pattern and practice against it, “everyone can actually operate it” beat “it looks like a 909.”

06 · field notes

What the plan didn’t see coming

An audit reads code. It doesn’t run it. Two things only surfaced once the rebuild was actually executing.

found during side b · nexusUI bootstrap

Side A shipped without the app ever running in a browser

A’s checks were lint, unit tests and a successful build — never a real page load. The first time v2 actually opened in Chrome, the dials and grid were blank. NexusUI bootstraps itself with a setTimeout(fn, 0) that builds its widgets and then calls its own onload hook — but main.js is an ES module now, so it has to resolve its entire import graph over the network before it executes a single line, and it was consistently losing that race. Fixed with a small classic-script bridge and a safe retry. The lesson is blunt: “the tests pass” and “the app runs” are different claims.

decided during side c

“Spike then decide” had exactly one real answer

I’d framed the NexusUI decision as genuinely open. Once I actually costed the parallel-accessible-layer option — two implementations of every gesture, forever — it wasn’t close. Some decisions look like forks until you price them.

07 · context

The wider landscape

If you’re building anything in this space, it’s worth knowing what else is out there and what the primitives are.

The APIs

The Web Audio API is the whole foundation — AudioContext, AudioBufferSourceNode for one-shot samples, GainNode for volume, decodeAudioData to turn a downloaded .wav into a playable buffer, and the sample-accurate start(when) scheduling that makes tight timing possible. Around it, localStorage for persistence, the navigator.clipboard API for share links, and Web Workers for the un-throttled timer. Nothing here needs a framework.

The libraries

Tone.js is the obvious “why not just use this” — it wraps Web Audio in a musical timeline (Tone.Transport with BPM, swing and loop points built in), sequences, samplers and effects, and it solves the two-clocks scheduling for you. I didn’t use it for two reasons: it’s a sizeable dependency for a four-sample toy, and the scheduling problem is exactly the thing a portfolio piece should show you understand rather than import. If RhythmBrownBox grew song sections, swing, and effects, Tone.js would be the right call. NexusUI covers the interface half of that space. For tooling, Vite and Vitest are close to the default for a no-framework project now — Vitest reuses Vite’s transform pipeline so tests run against the same module resolution the app does.

Other browser drum machines

Roughly by ambition:

  • One Motion Drum Machine — the best-known free browser step sequencer, more voices and swing.
  • TR-808 and TR-909 web recreations, and 808303.studio (Roland × Plan8), which runs full 808/303 emulations in WebAssembly.
  • Splice Beat Maker — free browser sampler and sequencer tied to Splice’s sample library.
  • Chrome Music Lab — Google’s educational Web Audio toys; the Rhythm and Song Maker experiments are basically this project’s friendly cousins.
  • Strudel — a JavaScript port of TidalCycles for live coding patterns, if you’d rather type your rhythms than click them.
  • BandLab and Soundtrap — full browser DAWs, where a drum machine is one instrument among many.
  • Audiotool — a browser modular studio with faithful 808/909 devices.

RhythmBrownBox sits at the small end of that list on purpose. It’s four sounds, a grid, and a tempo knob you can actually reason about — a practice tool, not a studio.

08 · status

Where it stands

rhythmbrownbox/ is untouched and still live; it’s the linked portfolio piece. rhythmbrownbox_v_2/ is where all of the above happened — three commits, one per side, sitting ahead of origin/master locally, ready for review before it goes anywhere public.

What’s genuinely still open is beyond what the audit asked for: a full roving-tabindex ARIA grid instead of Tab-through-every-cell (fine today, just a lot of tab stops at 32 steps), swing, and per-track lengths for polymeter. The maximum-scale=1 in the viewport tag should come out too — it disables pinch zoom, which is its own accessibility problem.

None of that blocks calling the rebuild done. The box stops when you tell it to, remembers your pattern, keeps time with a backgrounded tab, and someone using a keyboard can play it. That’s the tool I wanted when I was practising against v1.