Engineering
The unit
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
The tell that a small project is worth a rebuild is that you’ve used it enough to have complaints. Mine were specific:
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.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
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.
index.html (46 lines), main.js (~220), main.css (91) — no build step, no modulessetIntervalpackage.json, no runner, no CIindex.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.
Three real bugs, all silent:
.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.@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.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
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
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.
The scenario first:
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:
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:
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.
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.
04 · side b
The speed knob is now a BPM control, 40–240, with the conversion to a step interval pulled into its own tested module:
// 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.
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.
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.
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.
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
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:
<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.
<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
An audit reads code. It doesn’t run it. Two things only surfaced once the rebuild was actually executing.
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.
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
If you’re building anything in this space, it’s worth knowing what else is out there and what the primitives are.
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.
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.
Roughly by ambition:
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
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.