Skip to content

nigelfds/drumcoach

Repository files navigation

🥁 DrumCoach

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.


Quick start

# 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 access

A 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 over http://localhost. In production the app is a fully static site (see Hosting below); it makes no server calls.


Hosting (GitHub Pages)

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)

One-time setup

  1. Point Pages at the branch — repo Settings → Pages → Build and deployment → Source → Deploy from a branch, then choose gh-pages and folder / (root).
  2. 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
  3. Seed the branch (first deploy):
    git push --force origin "$(git subtree split --prefix public main):refs/heads/gh-pages"

Deploying

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"

Notes

  • 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 push overwrites the gh-pages branch, the GitHub Pages CNAME file must live in the repo at public/CNAME (containing drumcoach.nig.fm) so it survives each deploy. A DNS record alone won't keep it — without public/CNAME, the next deploy wipes the custom-domain setting and Pages falls back to the github.io URL.

Cross-device sync (optional · Firebase)

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. localStorage stays the offline cache; only the data maps sync (your current selection stays per-device).

Setup

  1. Create a project at https://console.firebase.google.com and add a Web app.
  2. Authentication → Sign-in method: enable Anonymous and Google.
  3. Authentication → Settings → Authorized domains: add your Pages domain (drumcoach.nig.fm) so the Google popup works in production.
  4. 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;
        }
      }
    }
    
  5. Paste your web config into public/js/firebase-config.js (apiKey, authDomain, projectId, appId). The apiKey is 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.


Project plan

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

Architecture at a glance

            ┌─ 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)

✅ TODO / progress

Commits are made as items are ticked off.

  • T0 — Scaffold project: README, package.json, .gitignore, brew setup.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

How the drum recognition works

  1. 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.
  2. 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.
  3. Classification — a nearest-profile scorer compares those features to a per-voice profile and picks the closest. The defaults (DEFAULT_PROFILES in audio-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 & calibration

  • 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.

Kit profiles (saved calibration)

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).

Voice rejection

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.


Timing & scoring

  • 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).

Skill levels (don't expect pro accuracy from a beginner)

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.

Latency calibration

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.


Scripts

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".

Testing

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.

Recognition debug panel (?debug)

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 suite

The 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.sh handles this via nvm. The app itself runs on Node 18+.


License

MIT

About

A small web app to help beginners with drum lessons

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages