Skip to content

Repository files navigation

SPM — strokes per minute

A drum stroke counter for electronic kits. Counts how many strokes you play in a timed window and reports them as strokes per minute, the way BPM reports tempo.

It reads MIDI straight from your module, so it counts what the brain actually triggered rather than trying to hear it through a microphone. That makes it accurate enough to argue with.

SPM mid-run, racing the ghost of a previous best

Mid-run: the count is readable from the throne, and the ghost of your best run is 37 strokes behind with 18.8 seconds left.

Works with any electronic kit that sends MIDI note-on — Roland TD-series, Alesis, Yamaha, 2box, or a standalone trigger interface. Nothing in it is module specific: you point it at the note your pad sends, and the built-in Test screen finds that for you.

For scale, the bass-drum record is 1075 strokes in 60 seconds (Tim Waterson, 2006); hands is 1208 (Tom Grosset, 2013).


Quick start

Pick whichever you prefer — they produce the same thing.

Option 1 — build it yourself (no SmartScreen warning)

Clone the repo and double-click Build SPM.bat. It compiles SPM.exe in about a second using the C# compiler that is already part of Windows. Nothing is downloaded, nothing is installed, and there is no toolchain to set up.

git clone https://github.com/ziodex/spm-drums.git

This is the recommended route, because a file you compiled locally is never tagged as coming from the internet, so Windows doesn't show the "Windows protected your PC" warning that a downloaded unsigned executable gets. You also get to read the ~300 lines of launcher/Spm.cs first, which is a reasonable thing to want to do before running a binary that opens a socket.

Use git clone rather than Download ZIP if you can. Windows tags everything extracted from a downloaded ZIP as internet-sourced, which can make it prompt before running the .bat. If you do use the ZIP, right-click it → Properties → tick Unblock before extracting.

Option 2 — download the prebuilt exe

Grab SPM.exe from the latest release and double-click it. One file, about 90 kB, nothing to unpack.

It's unsigned, so Windows will likely show "Windows protected your PC". Click More info → Run anyway, or right-click the file → Properties → tick Unblock. Code signing certificates cost a few hundred dollars a year and still need time to build reputation, which is hard to justify for a free tool — hence option 1.

Option 3 — no binary at all

The app is three static files with no build step. Serve the app/ folder with anything and open it in Chrome:

cd app && python3 -m http.server 8765

then visit http://localhost:8765/spm.html. This is also the route for macOS and Linux. It has to be localhost rather than a file:// path — see why a server below.

Requirements

  • Chrome or Edge. Firefox and Safari do not implement the Web MIDI API, so the page cannot work there no matter what you allow.
  • Windows 10/11 for SPM.exe, which needs only .NET Framework 4.x — part of Windows out of the box. The HTML itself runs anywhere Chrome does.
  • An electronic kit or MIDI trigger interface.

First run

  1. Allow the MIDI permission prompt. If you miss it, click the padlock in Chrome's address bar and enable MIDI.
  2. Pick your input in Setup. The app auto-selects anything that looks like a drum module.
  3. Press T to test. Hit a pad and confirm it says Counts as KICK.
  4. Press Start, then Space.

Test connection

Press T any time. It answers the question that actually matters before a run: will what I play be counted?

Hit a pad and the note number appears in large type, colour-coded — green if that stroke would count, red if it wouldn't, with the reason spelled out:

  • Counts as KICK
  • Not counted — KICK listens for note 36
  • Ignored — velocity 10 is below the floor of 40

That third case is why this screen exists. A stroke that arrives but gets silently discarded by the velocity floor looks identical to no signal at all.

Underneath, every note you've hit accumulates as a chip — note number, likely pad name (kick, snare rim, hi-hat open…), and hit count. Hit each pad in turn and you've mapped your kit. Click any chip to start or stop counting that note; amber chips are the ones counting now. Usually faster than typing note numbers into settings.

Strokes played while the test screen is open never start or feed a run.

Playing

By default Space starts a 3 second countdown, then the clock runs whether you're playing or not — strokes before zero don't count, so a false start costs you. Set Countdown to Off and it reverts to starting on your first stroke, with that stroke counting as number one. Off is the traditional stroke-counter behaviour; the countdown is better when you want to already be at full speed as the clock opens.

Key
Space start / reset (in freestyle, stops the run)
T test MIDI input
H run history
S setup
F fullscreen
Esc abort the run

Test, History, and Setup are locked while a run is armed, counting down, or running. Press Esc first if you need to change configuration; this prevents a live score from silently losing strokes or being saved under different settings.

The display goes red for the final ten seconds. During the run you get live strokes-per-second and a projected final score, which is the number to chase mid-run. Afterwards, a per-second histogram shows exactly which second you started fading.

What the result screen tells you

The result screen: score, tempo, evenness, fatigue, and the per-second histogram

Three numbers beyond the raw count, because two runs of 600 can be completely different performances.

Tempo — your score converted to BPM, since that's the unit you actually rehearse in. 669 kicks in a minute played as 16ths is 167 BPM, which means something to a drummer in a way that "669" doesn't. Set the reference subdivision in Setup — 8ths, triplets, 16ths, sextuplets or 32nds. It changes only how the tempo is reported, never what gets counted.

Evenness — the standard deviation of the gap between strokes, in milliseconds. This is the one no stroke counter usually reports, and it's arguably more useful than the count. A metronomic 600 and a lumpy 600 score identically but play nothing alike; here they read ±0.4 ms and ±13.8 ms. It's shown alongside the same figure as a percentage of your average gap, so runs at different speeds stay comparable. Green under 8%, red over 16%.

Watch it while chasing a personal best — if the count climbs while evenness gets worse, you're buying speed with control.

A genuine pause mid-run inflates this, since one huge gap dominates a standard deviation. It's meaningful for continuous max-effort runs, which is what it's for.

Fatigue — your rate over the last third against the first third. −18% means you faded; a positive number means a negative split, finishing stronger than you started. It separates fast from fit, which are different problems with different fixes.

All three are recorded per run, shown in the history table, and exported to CSV.

Run history

Press H. Date, mode, score, BPM, evenness, fatigue, and peak for every run, plus a sparkline of its per-second shape so you can see at a glance whether you held pace or blew up at second twenty. Your best run per mode and counted-note map is tagged.

Discarded runs — where the MIDI port dropped mid-run — are not recorded, for the same reason they can't set a best: the count is missing strokes.

Last 300 runs kept. Records live in browser localStorage, so they're per-machine and per-browser.

Ghost race

Your best run for the current mode is replayed alongside the live one, stroke for stroke, on the same clock. A second counter under the big number ticks up exactly as your record did, with the gap between you shown in green when you're ahead and red when you're not.

This is the difference between "I need 600" and watching yourself pull away from — or get reeled in by — the run that actually set the record. Unlike the pace target, it doesn't assume an even pace: it reproduces how you really played, including where you faded.

Ghosts are per mode, duration, and counted-note map, so KICK 60s and SNARE 60s race their own records and never each other. Changing the notes also starts a separate record. A new best replaces the ghost; a discarded run never becomes one. Clearing a best clears its ghost too. Turn it off in Setup if you'd rather not be chased.

Stored as the timings of that one run — a 700-stroke record is about 3.5 kB.

Pace target

Set a target score, or click Use best to chase your record. During the run a fifth readout shows how far ahead or behind even pace you are — green ahead, red behind.

The maths is target × (elapsed ÷ duration) against your actual count, so 30 seconds into a 60-second run chasing 600, you need 300 on the clock to read +0. Targets apply only to fixed-duration runs; freestyle hides the readout.

CSV export

Both live in the history panel.

History CSV — one row per run: timestamp, mode, notes counted, selected duration, actual elapsed time, score, average, peak, target, BPM, evenness (both ms and %), fatigue, then sec1…secN columns holding the per-second counts. Actual elapsed time matters for freestyle runs, where Space determines the end. You can chart the shape of every run you've ever done without further processing.

Last run CSV — one row per stroke:

stroke time_ms gap_ms note velocity rate_per_s
1 0 36 90
2 84 84 36 103 11.90

gap_ms is the interesting column — sort by it and anything implausibly small is a double-trigger. That's exactly how the 35 ms lockout default was arrived at.

Files are UTF-8 with a BOM so Excel opens them without an import wizard. Full stroke detail is kept only for the most recent run; history keeps per-second counts, so it stays small no matter how much you play.

Settings that matter

PresetKick counts note 36. Snare counts head (38) + rim (40), because a hard stroke on a mesh pad often reads as a rimshot and you don't want those strokes vanishing. Cross-stick (37) is excluded — it isn't a stroke you'd roll with. Custom leaves the fields alone.

Each preset keeps its own record, so KICK 60s and SNARE 60s don't overwrite each other.

Counted notes — comma separated. Kick is normally 36; some kits use 35 as a second kick note, in which case enter 35, 36.

Velocity floor — ignores anything softer than this. Raise it if crosstalk from the rest of the kit registers phantom strokes. Default 1, counting everything.

Retrigger lockout — ignores a second trigger arriving within this many milliseconds. Default 35 ms, which still allows almost 29 independently counted strokes/sec while rejecting both measured double-trigger intervals below.

That default came from measurement, not guesswork. Capturing real playing found a 21 ms repeat on the kick (47 strokes/sec) and a 34 ms repeat on the snare (29/sec). Neither is humanly playable, so both are the pad retriggering itself. They ran at roughly 1% of all strokes — over a 1000-stroke run, about ten phantom hits, easily enough to fake a personal best. Lower the setting if you suspect legitimate strokes are being swallowed; 0 trusts the module completely.

Security and privacy

Reasonable things to want to know about a small unsigned binary that opens a socket:

  • It listens on loopback only. The listener binds IPAddress.Loopback, not IPAddress.Any, so nothing outside your machine can reach it.
  • It makes no outbound network connections. No telemetry, no analytics, no update check.
  • The page loads no external resources — no CDN, no web fonts, no trackers, no third-party anything. The only requests it makes are for its own stylesheet and script from the same loopback origin.
  • Your data never leaves the browser. Scores and history live in localStorage. CSV export writes a local file via a blob URL.
  • It's reproducible. build.ps1 rebuilds the exe in one command using the C# compiler already present in Windows. No package manager, no dependencies, nothing fetched. Read launcher/Spm.cs — it's about 300 lines — and build it yourself.

The exe does two privileged-looking things, both visible in the source: it opens a TCP listener on port 8765, and it launches Chrome or Edge with a localhost URL.

Why a server, and not just the HTML

The Web MIDI API only runs in a secure context. Opening the file directly gives a file:// origin, which is not secure, and MIDI access throws immediately. http://localhost is treated as secure, hence the tiny server.

The Windows launcher uses a raw TcpListener rather than .NET's HttpListener, because the latter needs a netsh URL reservation and fails with "Access is denied" for non-admin users.

Port 8765 is pinned deliberately. Browsers scope localStorage by origin, so serving on a different port would be a different origin and every saved score would silently disappear. If something else holds the port, the app says so rather than quietly moving.

Connection — send MIDI down the long run, not USB

If your module is far from the computer, do not bridge the distance with USB. USB 2.0 allows a maximum of 5 metres per passive cable segment. A 6 m run is out of spec, and the module will enumerate and drop off the bus repeatedly — measured on one such setup, 47 connect/disconnect transitions in 40 seconds, roughly one a second. Strokes played while the port is gone are never delivered.

Send MIDI over the long run instead. MIDI DIN is a slow (31.25 kbaud), opto-isolated current loop designed for exactly this — the spec allows 15 m:

module MIDI OUT → long MIDI cable → audio interface MIDI IN → short USB → PC
module MIDI OUT → long MIDI cable → USB-MIDI interface → PC

Measured on the MIDI path through an audio interface: 0 transitions, versus 47 over the long USB cable. An active/repeater USB extension or a powered hub partway along the run also works, as does simply using a shorter cable.

The app defends itself either way

It shows WAITING FOR <device> while the port is missing and reattaches the instant it returns — matching by name, since the port id changes across a re-enumeration. It will not silently fall back to a different MIDI device, because listening to the wrong port is worse than listening to none. And if the device drops during a run, the result is marked Discarded and refused as a personal best.

Example note map

Captured from real playing on a Roland TD-30, all on channel 10. Most modules follow this General MIDI layout, but yours may differ — the Test screen is authoritative:

Note Pad Note Pad
36 Kick 41 Tom 4
38 Snare head 43 Tom 3
40 Snare rim 45 Tom 2
26 Hi-hat edge 48 Tom 1
46 Hi-hat open

A rimshot sends 40 only, not 38 and 40 together — but even if your kit paired them, the retrigger lockout applies across the whole counted set rather than per note, so a pair arriving within 35 ms still counts once.

Modules also transmit MIDI clock and active sensing. Both are filtered out — only note-on messages are counted.

Accuracy notes

Strokes are timestamped from MIDIMessageEvent.timeStamp, which Chrome takes closer to the hardware than the JS callback, so counting doesn't drift with UI load. The code sanity-checks that stamp against performance.now() and falls back if a driver reports something implausible.

The timing window is half-open: the first stroke at 0 ms counts, while a stroke stamped exactly on or after the bell is dropped. Freestyle uses the Space press as its true stop time, including any silence after the last stroke, so its average and BPM are honest.

Building

Double-click Build SPM.bat, or from a terminal:

powershell -ExecutionPolicy Bypass -File launcher/build.ps1

Compiles with the csc.exe that ships inside Windows — no toolchain to install, no dependencies, nothing fetched from the network. app/spm.html, app/spm.css and app/spm.js are embedded into the executable as resources, which is what keeps the result a single self-contained file even though the sources are split.

A locally compiled binary carries no mark-of-the-web — the alternate data stream Windows attaches to downloaded files — so SmartScreen's reputation check doesn't apply to it.

While editing the page, Start SPM.bat serves it from disk instead, so you can just refresh the browser rather than rebuild.

Tests

The counting tests use synthetic MIDI timestamps, so boundary and retrigger behaviour is repeatable without a drum module connected. They need Node.js 20 or later, but Node is not required to build or run SPM:

npm test

GitHub Actions runs the same suite and builds the Windows executable on every push and pull request.

Version

The version lives in exactly one place — a <meta> tag near the top of app/spm.html:

<meta name="version" content="1.1.0" />

The page reads that tag at runtime; build.ps1 extracts it and stamps it into the executable's assembly attributes. So it shows in the Setup panel badge, the launcher window, and SPM.exe → Properties → Details → File version — with no second copy to forget. The build fails loudly if the tag is missing. A suffix like 1.1.0-beta is fine; the numeric part becomes the Windows file version.

Files

Build SPM.bat        double-click to build SPM.exe yourself
Start SPM.bat        double-click to run from source, no build

app/                 the browser app — this is the whole product
  spm.html             markup
  spm.css              instrument-panel styling
  spm.js               application logic

launcher/            Windows plumbing that serves app/ on loopback
  Spm.cs               the host: embeds the three files, serves them, opens Chrome
  build.ps1            compiles SPM.exe
  serve.ps1            same server in PowerShell, reading from disk

app/ is plain static assets — no bundler, no transpiler, no node_modules. Edit a file, refresh the browser. Serving that folder with anything at all is enough to run the app, which is the macOS and Linux route.

launcher/ exists only because Windows users shouldn't need a web server installed. Its files are the only thing that isn't portable.

The three app files are embedded into SPM.exe at build time, so the executable remains a single self-contained file despite the split. It's built into the repository root, where you'd look for it.

Both servers serve those three paths and nothing else — the route table doubles as a whitelist, so no other file on disk is reachable.

SPM.exe is not committed — it's attached to each release. Committing a binary would grow the repository on every rebuild and never shrink it.

Browser storage uses the spm.* namespace.

Troubleshooting

"No MIDI inputs found"

  • Something else has the port open. Many vendor MIDI drivers are legacy MME-style and hand out the port exclusively — one application at a time. Close any DAW and reload.
  • The module isn't transmitting. Roland modules have a MIDI TX/RX switch that must be on for the USB port to send anything.
  • The driver didn't install cleanly. Reinstall your module's driver, or use a class-compliant USB-MIDI interface, which needs no driver at all.

Nothing counts, but notes are arriving — press T and hit the pad. It will tell you exactly why: wrong note, or velocity below the floor. Click the chip to count it.

Counts double — raise the retrigger lockout above 35 ms, or fix the pad's retrigger cancel setting in the module. Export the last run and sort gap_ms to measure the repeats.

Stray counts while playing other drums — raise the velocity floor, or check the module's crosstalk cancellation.

Firefox or Safari — neither implements Web MIDI. Use Chrome or Edge. The launcher looks for one of those explicitly rather than trusting your default browser.

Contributing

Issues and pull requests welcome. The app is three static files with no build step, so you can edit the files in app/, run Start SPM.bat, and refresh. See CONTRIBUTING.md for the development and reporting workflow.

If you're reporting a counting problem, the last run CSV is the most useful thing to attach — it has every stroke's timing, note and velocity.

License

MIT.

About

A drum stroke counter for electronic kits. Counts how many strokes you play in a timed window and reports them as strokes per minute, the way BPM reports tempo.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages