A browser-based, mobile-first drum-practice coach — a static web app (Node is only used to serve it locally). Play an acoustic or electronic kit near your microphone and DrumCoach will:
- Listen to your microphone and detect drum onsets in real time
- Classify each hit as kick, snare, tom 1/2/3, hi-hat, ride, or crash
- Notate what you played on a live, scrolling standard drum staff
- Measure your timing — live BPM, steadiness, and how far you are drifting, with Beginner / Intermediate / Pro ranges so feedback fits your level (not just pro tightness)
- Metronome with visual + audible click so you can lock to a tempo
- Build & score patterns — opens with a 1-bar eighth-note rock beat (hi-hats throughout, snare on 2 & 4, kick on 1 & 3); add toms/ride/crash, change resolution, and score how cleanly you play it
- Play your pattern back through a built-in drum synth (the tempo follows the metronome), with an optional loop
Inspired by Tone.js' step sequencer and Chrome Music Lab: Rhythm.
The UI is a light, mobile-first design (a kid plays with a phone/tablet by the
kit): mic → 1 Set your beat → 2 Play along → Live staff → Patterns → Settings,
with a guided onboarding sheet. The design template and rebuild plan live in
design/ (drumcoach-redesign.html + REDESIGN.md).
⚠️ On drum recognition: classifying drum hits from a single microphone is a hard problem. DrumCoach uses lightweight spectral heuristics (onset detection + frequency-band energy + spectral centroid), which works well for isolated hits and practice, but is not a trained ML model. Calibration is provided to adapt to your kit and room.
# 1. Use the project's Node (pinned to 20 via .nvmrc)
nvm install && nvm use # or ./setup.sh to install the latest Node via Homebrew
# 2. Install dependencies
npm install
# 3. Run
npm start
# open the printed http://localhost:3000 and allow microphone accessA microphone and a modern Chromium-based browser (Chrome/Edge/Brave) are recommended for the best Web Audio support.
The Node server (
server.js) is only for local development — it serves the static client overhttp://localhost. In production the app is a fully static site (see Hosting below); it makes no server calls.
The app is a static client (everything in public/), so it's hosted on
GitHub Pages from a gh-pages branch that mirrors the public/ folder —
no server and no build step. The gh-pages branch is produced from public/
with git subtree, so public/ stays the single source of truth.
Live URL: https://drumcoach.nig.fm (custom domain → GitHub Pages)
- Point Pages at the branch — repo Settings → Pages → Build and
deployment → Source → Deploy from a branch, then choose
gh-pagesand folder/ (root). - Activate the deploy hook in your local clone (git config isn't stored in
the repo, so do this once per clone):
git config core.hooksPath .githooks
- Seed the branch (first deploy):
git push --force origin "$(git subtree split --prefix public main):refs/heads/gh-pages"
Just push main — the tracked pre-push hook (.githooks/pre-push) deploys for
you:
git push origin main # → also updates origin/gh-pages from public/The hook splits the public/ subtree and force-pushes it to gh-pages
(a generated branch, so force is intentional — it avoids "non-fast-forward"
rejections when gh-pages diverges, e.g. GitHub adding a CNAME commit). It
only fires for main/master pushed to origin, doesn't recurse on the inner
gh-pages push, and if the deploy fails it just warns (your main push still
goes through). To deploy manually at any time:
git push --force origin "$(git subtree split --prefix public main):refs/heads/gh-pages"- The microphone needs a secure context; GitHub Pages is served over HTTPS, so mic capture works.
- Kit profiles and patterns are saved in the browser's
localStorage, so there's nothing to persist server-side. - All asset paths are relative, so the app works whether it's served at the
custom-domain root (
drumcoach.nig.fm) or under a/drumcoach/project-page subpath. - Custom domain + subtree deploys: because
git subtree pushoverwrites thegh-pagesbranch, the GitHub PagesCNAMEfile must live in the repo atpublic/CNAME(containingdrumcoach.nig.fm) so it survives each deploy. A DNS record alone won't keep it — withoutpublic/CNAME, the next deploy wipes the custom-domain setting and Pages falls back to thegithub.ioURL.
By default DrumCoach stores kit profiles and patterns only in the browser's
localStorage. You can optionally enable cross-device sync backed by
Firebase. It's off until configured — the app works exactly the same without it.
How it behaves:
- On load you're signed in anonymously, so your kits/patterns back up to the cloud immediately — no login wall.
- The ☁ Sync across devices button (top right) links that anonymous account to Google (one click). The same uid is kept, so your data carries over.
- On another device, click the button and sign in with the same Google account → the same kits/patterns appear.
- If that Google account was already used on another device, signing in merges
this device's local data into the account (union by id, newest wins) — nothing
is lost.
localStoragestays the offline cache; only the data maps sync (your current selection stays per-device).
- Create a project at https://console.firebase.google.com and add a Web app.
- Authentication → Sign-in method: enable Anonymous and Google.
- Authentication → Settings → Authorized domains: add your Pages domain
(
drumcoach.nig.fm) so the Google popup works in production. - Firestore Database: create one (production mode) and set these rules so each
user can only touch their own document:
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /users/{uid} { allow read, write: if request.auth != null && request.auth.uid == uid; } } } - Paste your web config into
public/js/firebase-config.js(apiKey,authDomain,projectId,appId). TheapiKeyis not a secret — Firebase web config is public; security comes from the rules above.
Implementation: public/js/cloud-sync.js (loads the Firebase SDK from the CDN
only when configured) syncs users/{uid} ↔ the ProfileStore/PatternStore
maps. Known v1 limitations: simultaneous edits on two devices are last-write-wins,
and a delete can reappear if a long-offline device re-syncs stale data.
The app is split into focused modules so each can be built and committed on its own:
| Module | File | Responsibility |
|---|---|---|
| Server | server.js |
Serves the static client over HTTP (local dev only) |
| Notation | public/js/notation.js |
Draws the live scrolling drum staff |
| Audio engine | public/js/audio-engine.js |
Mic capture, onset detection, classification, sensitivity, voice rejection |
| Timing | public/js/timing.js |
BPM estimation + drift / steadiness / tightness |
| Metronome | public/js/metronome.js |
Scheduled click + visual beat indicator |
| Sequencer | public/js/sequencer.js |
1–16 bar pattern (quarter/eighth/sixteenth) + scoring |
| Drum synth | public/js/drum-synth.js |
Web Audio drum-voice synth for previews + pattern playback |
| Pattern player | public/js/pattern-player.js |
Lookahead scheduler that plays the pattern (looping optional) |
| Pattern store | public/js/pattern-store.js |
Persist named patterns per kit to localStorage |
| Kit profiles | public/js/profiles-store.js |
Persist named calibration profiles to localStorage |
| Cloud sync | public/js/cloud-sync.js + firebase-config.js |
Optional cross-device sync via Firebase |
| App | public/js/app.js |
Wires modules to the UI; owns view state |
┌─ shared AudioContext clock ─────────────────────────────┐
mic ─► AudioEngine (onset + classify) Metronome ─► Timing (BPM/drift/tight)
│ │
└────────────► App (UI state) ◄───────┘
│ ├─► Live staff (Notation) + Play-along metrics
│ └─► Sequencer (grid + scoring) ─► PatternPlayer ─► DrumSynth
▼
ProfileStore / PatternStore ◄─► localStorage ◄─► CloudSync (Firebase, optional)
Commits are made as items are ticked off.
- T0 — Scaffold project: README,
package.json,.gitignore, brewsetup.sh, git init - T1 — Node static server (
server.js) + base HTML/CSS shell - T2 — Drum staff notation renderer (live scrolling canvas)
- T3 — Microphone capture + onset detection
- T4 — Drum classification (kick / snare / toms / hi-hat / ride / crash) + calibration
- T5 — Timing engine: live BPM, steadiness, drift indicator
- T6 — Metronome with audible click + visual beat
- T7 — Pattern sequencer: annotate 4/8/16 bars and score accuracy
- T8 — Wire everything in
app.js+ polish UI - T9a — Persist named calibration kit profiles to localStorage (save / switch / delete / forget)
- T9b — Play the pattern through a built-in drum synth, with an optional loop
- T9c — Persist named patterns per kit to localStorage (save / load / delete, auto-restore)
- T9d — Optional cross-device sync (Firebase anonymous auth → Google link, merge on conflict)
- T10 — Light, mobile-first redesign (
design/REDESIGN.md): onboarding sheet, plain-language metrics + ⓘ glosses, single shared tempo, add-drums (toms/ride/crash) to the pattern, subdivision selector, two-tap delete, drum-type icons + synth previews - T11 — Recognition tuning: measured default profiles, a single sensitivity control (only-loud → catch-quiet), and "Calibrate to the built-in kit" auto-calibration
- T12 — Testable loopback audio path + "Test recognition" self-test, Playwright e2e suite (
./test-e2e.sh), low-cluster fix (peak-frame + kick/tom tiebreak), and multi-drum detection — a low drum plus a layered cymbal and/or snare at once (e.g. the full kick + snare + hi-hat backbeat) - T13 — Beginner-friendly timing feedback: Beginner / Intermediate / Pro skill levels that scale the scoring windows + feedback ranges, a Perfect scoring tier, and latency calibration measured during "Calibrate to the built-in kit" (subtracted from drift / tightness / scoring)
- Stretch — export MIDI
- Onset detection — the engine tracks short-term energy (spectral flux). A sharp rise above an adaptive threshold marks a new hit and starts a short capture window.
- Feature extraction — for the captured window it computes total energy, per-band energy (sub/low/low-mid/mid/high/very-high), spectral centroid, and decay length.
- Classification — a nearest-profile scorer compares those features to a
per-voice profile and picks the closest. The defaults (
DEFAULT_PROFILESinaudio-engine.js) are measured by FFT'ing each built-in DrumSynth voice, so the pattern-maker's own sounds are recognised out of the box. Calibration overrides a voice's profile for your real kit.
- Sensitivity (Settings) is one slider, Only loud hits → Catch quiet taps. It tightens both gates together: how sharp the attack must be (flux vs the adaptive noise floor) and how far above the ambient floor a hit must be. Default leans strict, so soft taps and background noise are ignored.
- Calibrate to the built-in kit — one tap plays each synth voice through the speakers while the mic listens, learning the real speaker→room→mic path.
- Calibrate to your real kit — pick a drum, tap it a few times.
- Test recognition — plays each voice and shows a per-voice scorecard ("Snare 3/3 ✓ · Kick 0/3 · 2× Tom 3") so you can see what's working.
- Voice rejection (below) keeps talking/singing from firing false hits.
The classifier scores the loudest (peak) frame of each hit (more stable than the rising edge) and adds a kick-vs-floor-tom tiebreak — the kick sweeps down into the sub band over its decay while the floor tom doesn't. With those, the built-in sounds recognise cleanly (~24/24 in the self-test). A real kit may still want calibration for its own close pairs.
Multiple drums at once: the classifier only ever picks one primary (low) drum per onset, so simultaneous hits are recovered with two independent layer-detectors that run over the candidate window. Both require ≥2 sustained frames (the onset frame is skipped) so a low drum's one-frame broadband attack click can't trigger a phantom layer:
- Cymbal layer — a high-band spike (≥6 kHz). Kick/toms have no energy there, so it means a hi-hat/ride/crash is on top; the type comes from the ≥6 kHz ratio (hat ≈ 0.92, ride ≈ 0.87, crash ≈ 0.79). → e.g. kick + hi-hat, tom + crash.
- Snare layer — a noisy mid-band spike (250–2000 Hz, high spectral flatness). Kick/toms have a near-silent mid and a tonal tom/kick click sits at low flatness, so this uniquely marks a snare struck over the low drum. → e.g. kick + snare, and the full kick + snare + hi-hat backbeat.
A snare's own body also spikes the ≥6 kHz band, so when a snare layer is present the cymbal layer is suppressed (no phantom crash on a bare kick + snare). The one case that stays unrecoverable is a hi-hat struck exactly on top of a louder snare: their ≥6 kHz energy blends inseparably, so only the snare is reported at that instant — but in a real groove the hat is on every eighth and is still detected on all the beats where it isn't buried under the snare. A lone snare (no low drum under it) is reported as just the snare.
Calibration is stored as a named kit profile so you can keep one per drum
kit / room and switch between them. Profiles persist in the browser via
localStorage (key drumcoach.kits.v1), handled by public/js/profiles-store.js:
- Save the current calibration under a name (re-saving the same name updates it).
- Pick a saved kit from the dropdown to load it; the last-used kit is restored automatically on the next visit.
- Delete removes a saved kit; Forget calibration reverts the live engine to default profiles without touching anything you've saved.
- Calibrating a voice while a kit is active auto-saves into that kit.
Saved patterns work the same way and are scoped to the active kit: name the
grid in the Pattern panel and Save it, pick one from the dropdown to load it,
Delete to remove it. The saved-pattern list follows whichever kit is active,
and the last pattern is restored on the next visit (stored under
drumcoach.patterns.v1, handled by public/js/pattern-store.js).
Microphones pick up talking and singing, which can fire false hits. The Voice rejection selector (in the Settings panel) filters these out without touching drum detection:
| Mode | What it does |
|---|---|
| Off | Pipeline unchanged — every onset is treated as a drum |
| Moderate | A 3-test gate: rejects an onset only when it is sustained (doesn't decay) and harmonic (low spectral flatness) and speech-banded (200–3400 Hz). All three are needed, so kick (wrong band), snare/cymbals (too noisy), and toms (decay too fast) are always kept |
| Aggressive (default) | The 3-test gate plus a voice-activity detector that also blocks pitched, speech-banded onsets struck while you're mid-talking |
The classifier watches ~130 ms of each hit before deciding, so a note appears ~130 ms later — but hits are timestamped at the true onset, so BPM, drift, and scoring stay exact. Calibration bypasses the voice gate entirely.
- Live BPM is derived from inter-onset intervals (median of recent hits, folded to a sensible 40–240 BPM range).
- In time is the coefficient of variation of your inter-onset intervals — how even your spacing is (Locked / Steady / Loose / Wobbly).
- Drift compares your hit times to the nearest metronome grid line; a running signed average tells you if you are rushing (ahead) or dragging (behind).
- Tightness is the spread (std-dev) of those grid errors, in ms — lower is tighter.
- Pattern score lines up detected hits against the annotated grid and reports perfect / hit / early / late / miss / extra and an accuracy % (perfect + clean hits = full credit, early/late = half).
The feedback ranges and scoring windows are not fixed at pro tightness — they scale with a Beginner / Intermediate / Pro selector (Beginner is the default, and the choice is saved). The windows widen for beginners so steady, musical playing reads as "Steady / in the pocket" rather than always "Wobbly":
| Level | Clean hit | Perfect | "In the pocket" drift | Match window |
|---|---|---|---|---|
| Beginner | ±70 ms | ±35 ms | ±35 ms | ±150 ms |
| Intermediate | ±50 ms | ±25 ms | ±25 ms | ±130 ms |
| Pro | ±35 ms | ±18 ms | ±18 ms | ±120 ms |
These are grounded in rhythm perception (~20 ms = "just noticeable", ~50 ms starts
sounding off, >100 ms clearly off) and typical skill bands (pros sit ~10–20 ms SD;
beginners ~50–80 ms). Presets live in DIFFICULTY in public/js/app.js.
The onset detector reports a hit tens of ms after it actually happens (mic buffering + FFT window + output latency), which would otherwise show up as a fake "dragging" drift. Calibrate to the built-in kit (in Settings) now also measures this: it knows exactly when it triggers each test sound, so the median trigger→detect delay is stored and subtracted from every onset before drift, tightness and scoring. The measured value is shown under the timing panel and can be reset.
| Command | Does |
|---|---|
npm start |
Start the server on :3000 |
npm run dev |
Start with auto-reload (node --watch) |
./test-e2e.sh |
Run the Playwright e2e tests (selects the project's Node, installs the browser if needed). Same as npm run test:e2e. Extra args pass through, e.g. ./test-e2e.sh -g "two drums". |
The app is hard to test through a real microphone, so it has a loopback audio
mode: with ?loopback=1 the AudioEngine is fed by the DrumSynth (via a
MediaStreamDestination) instead of getUserMedia. The full
mic → onset → classify → staff pipeline then runs on synth audio, deterministically
and with no real mic. ?debug (implied by loopback) exposes detected hits at
window.__dc.hits and the last self-test result at window.__dc.lastTest.
Loopback mode also skips cloud sync so runs are hermetic.
Add ?debug to the URL (works in dev or prod, with the real mic or loopback) to
get a collapsible overlay that explains misclassifications:
- Profile comparison — overlay two voices' calibrated fingerprints (bands + centroid) with the distance between them, e.g. how close kick and floor tom are.
- Live hit inspector — for the last hit: its fingerprint, the distance to every profile (the classifier's scores), the chosen voice, the sub-tail value, and any layered snare/cymbal.
- Onset log — recent detections (time / voice / confidence), so a double- trigger (e.g. a snare that also fires a hi-hat) is obvious.
It reuses window.__dc and changes nothing in the recognition pipeline; it only
renders when ?debug is present (so the loopback e2e tests don't show it).
End-to-end tests live in tests/recognition.e2e.js:
./test-e2e.sh # selects the project's Node (.nvmrc), installs chromium, runs the suiteThe suite (5 tests) covers: the app loads clean, the default eighth-note rock beat is seeded, the recognition self-test produces a scorecard, a played pattern is detected, and two drums struck at once are both detected.
Requires Node ≥ 18.19 (Playwright's ESM loader) —
test-e2e.shhandles this vianvm. The app itself runs on Node 18+.
MIT