A browser-based DJ controller that turns an IKEA-style salad spinner into a physical music transport. A magnet attached to the rotating bowl passes a Hall-effect sensor once per revolution. An Arduino reports those pulses through Firmata and Web Serial; the browser estimates RPM and selects an audio playback-rate band.
Live app: https://djsally.val.run/
Original Node/Johnny-Five prototype: https://github.com/pchinjr/sit-and-spin-music-player
localStorage.A single Hall sensor measures pulse frequency but not direction. This version cannot distinguish clockwise from counterclockwise rotation and therefore cannot implement rewind.
Estimated US retail prices as of August 2026, before tax and shipping:
| Part | Quantity | Estimated cost | Notes |
|---|---|---|---|
| IKEA UPPFYLLD salad spinner | 1 | $9.99 | The physical crank and rotating bowl. |
| Arduino Uno Rev3 | 1 | $30–35 | A genuine Uno; compatible boards may cost less. |
| 49E Hall sensor module with LM393 comparator | 1 | $2–6 | Use the module's digital output. |
| Small permanent magnet | 1 | $1–3 | Mounted to the rotating bowl. |
| Male-to-female jumper wires | 3 | $1–3 allocated | One each for 5V, ground, and digital output; normally purchased as a pack. |
| USB 2.0 Type-A-to-Type-B cable | 1 | $6.50 | Standard Arduino Uno USB data cable. |
| Breadboard | 0 | $0 | Not used; the module connects directly to the Uno. |
| Estimated total | $50–64 | Buying every component new. |
If you already own the Arduino, jumper wires, and USB cable, the spinner, sensor module, and magnet add approximately $13–19.
Prices are estimates and may vary by seller. The IKEA US listing showed the UPPFYLLD spinner at $9.99, while Arduino listed its official USB 2.0 cable at $6.50 when this BOM was written.
The module combines two stages:
The potentiometer changes the comparator threshold; it does not amplify the magnet or debounce the signal.

| Hall module | Arduino |
|---|---|
| VCC | 5V |
| GND | GND |
| DO / D0 | Digital pin 9 |
| AO / A0 | Not used |
Use the module's digital output, not its analog output, with the current software.
Arduino pin 13 is deliberately unused. Pins 9 and 13 share Firmata digital port 1; toggling an LED on pin 13 added outgoing port messages to the sensor trace and complicated input behavior. The Hall module's onboard LED already provides a physical trigger indicator.
The observed hardware occasionally produces a sequence like:
HIGH → LOW → HIGH → LOW → HIGH
The second low transition may occur only a few milliseconds after the first. The software treats that as one physical pass.
The Arduino runs StandardFirmataPlus. The tested connection reported Firmata 2.5 at 57,600 baud.
The browser connection performs the Firmata handshake, configures pin 9 as a digital input, and subscribes to its values. No project-specific Arduino sketch is required after StandardFirmataPlus is installed.
The browser reads pin 9 directly through the Firmata I/O adapter:
board.io.pinMode(9, board.io.MODES.INPUT);
board.io.digitalRead(9, value => {
// Process digital transitions.
});
The software ignores repeated reports of the same digital state and considers a transition to LOW a candidate magnet pass.
A candidate low transition is accepted only if it occurs at least 50 ms after the previous accepted pulse:
if (lastAcceptedPulseAt && now - lastAcceptedPulseAt < 50) {
return;
}
This is a refractory period. It filters the observed 3–8 ms duplicate transitions while leaving substantial room for legitimate rotations. A 50 ms minimum interval corresponds to a theoretical ceiling of 1,200 RPM, well above the spinner's observed operating range.
The original prototype counted pulses for one second and multiplied the count by 60:
RPM = pulses per second × 60
The current app preserves that timing-based formula but uses a rolling one-second window. Each accepted pulse receives a performance.now() timestamp. Old timestamps are removed, and the remaining count is converted to RPM:
pulseTimestamps = pulseTimestamps.filter(
timestamp => now - timestamp < 1000
);
const rpm = pulseTimestamps.length * 60;
The calculation runs immediately after an accepted pulse and every 250 ms. A rolling window prevents continuous rotation from appearing as alternating nonzero and zero fixed buckets.
Because the window is exactly one second, displayed RPM is quantized in 60-RPM increments: 60, 120, 180, 240, 300, 360, 420, and so on.
Every accepted pulse restarts a 225 ms timer. If another pulse does not arrive before it expires, the app:
At observed top speed, legitimate pulses arrive roughly 130–150 ms apart, so 225 ms represents approximately one missed expected rotation. This makes the stop feel responsive rather than waiting for the full one-second RPM window to empty.
The default mapping is discrete:
| Measured RPM | Playback rate |
|---|---|
| 0 | Stopped |
| 1–299 | 0.5× |
| 300–400 | 1× |
| Above 400 | 2× |
The settings panel allows both thresholds and all three nonzero rates to be changed. Applied settings are saved under dj-salad-spinner:speed-bands-v1 in browser localStorage.
This is deliberately a banded controller rather than a continuously variable pitch calculation. The rolling RPM measurement has 60-RPM resolution, and discrete bands produce steadier, more intentional musical behavior.
Hall module │ digital LOW/HIGH ▼ Arduino Uno + StandardFirmataPlus │ USB serial at 57,600 baud ▼ Web Serial transport │ Firmata messages ▼ Browserified Johnny-Five / Firmata adapter │ board.io.digitalRead(9) ▼ app.js ├── pulse filtering ├── rolling RPM window ├── stop detection ├── playback-band selection ├── local settings └── platter state │ ├── HTMLAudioElement └── DOM/CSS interface
| File | Responsibility |
|---|---|
main.ts | Hono HTTP entry point; serves the HTML, client script, vendored bundle, and source redirect. |
index.html | Page structure, audio element, meters, platter, styles, speed settings, and debug UI. |
app.js | Web Serial connection, Firmata input, filtering, RPM calculation, playback control, settings, and UI state. |
vendor/johnny-five-web-serial.min.js | Browserified Johnny-Five/Firmata compatibility layer. |
Val Town serves these files directly. There is no frontend bundler or build step. The browser loads:
<script src="https://esm.town/v/std/catch"></script> <script src="/johnny-five-web-serial.min.js"></script> <script src="/app.js"></script>
The first script reports client-side errors to Val Town logs. The vendored bundle exposes window.JohnnyFiveWebSerial, and app.js contains the application logic.
main.ts is intentionally small. Hono routes requests to serveFile:
/ → index.html/app.js → browser application code/johnny-five-web-serial.min.js → vendored hardware library/source → Val Town source pageThe server does not proxy serial data or control the Arduino. Hardware communication occurs locally between the browser and the USB device.
The CSS rotation animation is always attached to the platter. The app changes only animation-play-state:
runningpausedPausing instead of removing the animation preserves the exact rotation angle. When spinning resumes, “PRAISE CAGE” continues from where it stopped instead of jumping back to the zero-degree position.
The page briefly starts and pauses the audio inside the Connect button gesture so later sensor events can control playback.
The combined control panel has two tabs:
Settings are local to the current browser profile. They are not stored on the Arduino or in Val Town.
HTMLMediaElement.playbackRate changes both speed and perceived pitch according to browser behavior; this is not a time-stretching audio engine.DO is connected to Arduino pin 9.LOW transitions.