Public
Browser Johnny-Five DJ turntable demo
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

DJ Salad Spinner

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

What it does

  • Connects directly to an Arduino from a Chromium browser using Web Serial.
  • Reads a digital Hall-sensor signal on Arduino pin 9.
  • Filters the occasional double pulse caused by magnet alignment and comparator chatter.
  • Estimates RPM from pulses observed during a rolling one-second window.
  • Maps RPM into configurable half-, whole-, and double-speed playback bands.
  • Stops playback promptly when the spinner stops.
  • Preserves the record platter's visual angle while stopped and resumes from that position.
  • Stores custom speed-band settings in browser localStorage.

A single Hall sensor measures pulse frequency but not direction. This version cannot distinguish clockwise from counterclockwise rotation and therefore cannot implement rewind.

Hardware

Components

  • Arduino Uno
  • 49E linear Hall-effect sensor module with LM393 comparator
  • Permanent magnet
  • Salad spinner with a rotating bowl
  • Jumper wires
  • USB cable between the Arduino and computer

Bill of materials

Estimated US retail prices as of August 2026, before tax and shipping:

PartQuantityEstimated costNotes
IKEA UPPFYLLD salad spinner1$9.99The physical crank and rotating bowl.
Arduino Uno Rev31$30–35A genuine Uno; compatible boards may cost less.
49E Hall sensor module with LM393 comparator1$2–6Use the module's digital output.
Small permanent magnet1$1–3Mounted to the rotating bowl.
Male-to-female jumper wires3$1–3 allocatedOne each for 5V, ground, and digital output; normally purchased as a pack.
USB 2.0 Type-A-to-Type-B cable1$6.50Standard Arduino Uno USB data cable.
Breadboard0$0Not used; the module connects directly to the Uno.
Estimated total$50–64Buying 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:

  1. The 49E Hall sensor produces an analog voltage based on magnetic-field strength and polarity.
  2. The LM393 comparator compares that voltage with a reference voltage set by the module's potentiometer and exposes a digital output.

The potentiometer changes the comparator threshold; it does not amplify the magnet or debounce the signal.

Wiring

Fritzing wiring diagram showing the 49E LM393 Hall sensor module connected to an Arduino Uno

Hall moduleArduino
VCC5V
GNDGND
DO / D0Digital pin 9
AO / A0Not 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.

Magnet and sensor placement

  • Mount the magnet so its face passes the sensitive face of the Hall sensor.
  • Keep the sensor and magnet mounts mechanically stable.
  • Start with a small gap, roughly 2–5 mm, and adjust as needed.
  • Tune the potentiometer until one physical pass produces one clean LED blink.
  • A tilted magnet or the curved path of the bowl can make the magnetic field cross the comparator threshold twice during one pass.

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.

Arduino firmware

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.

Core algorithm

1. Detect a magnet pass

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.

2. Filter double pulses

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.

3. Calculate RPM

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.

4. Detect a stop

Every accepted pulse restarts a 225 ms timer. If another pulse does not arrive before it expires, the app:

  • Clears the pulse history.
  • Sets RPM to zero.
  • Pauses the audio.
  • Pauses the visual platter.

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.

5. Select a playback band

The default mapping is discrete:

Measured RPMPlayback rate
0Stopped
1–2990.5×
300–400
Above 400

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.

Software architecture

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

Val Town files

FileResponsibility
main.tsHono HTTP entry point; serves the HTML, client script, vendored bundle, and source redirect.
index.htmlPage structure, audio element, meters, platter, styles, speed settings, and debug UI.
app.jsWeb Serial connection, Firmata input, filtering, RPM calculation, playback control, settings, and UI state.
vendor/johnny-five-web-serial.min.jsBrowserified 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.

Server layer

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 page

The server does not proxy serial data or control the Arduino. Hardware communication occurs locally between the browser and the USB device.

Visual platter behavior

The CSS rotation animation is always attached to the platter. The app changes only animation-play-state:

  • Nonzero RPM → running
  • Zero RPM → paused

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

Browser requirements

  • A Chromium-based browser with Web Serial support, such as Chrome or Edge
  • A secure context
  • User interaction to select and authorize the serial port
  • User interaction to satisfy browser audio autoplay requirements

The page briefly starts and pauses the audio inside the Connect button gesture so later sensor events can control playback.

Settings and debugging

The combined control panel has two tabs:

  • Speed bands edits RPM thresholds and playback rates.
  • Connection debug displays Web Serial, Firmata handshake, transmit, and receive messages.

Settings are local to the current browser profile. They are not stored on the Arduino or in Val Town.

Known limitations

  • One sensor cannot determine direction.
  • RPM resolution is 60 RPM because the algorithm counts pulses in a one-second window.
  • The 50 ms duplicate filter is fixed rather than adaptive.
  • The 225 ms stop timeout is tuned to the currently observed spinner cadence.
  • Comparator behavior depends on magnet alignment, sensor distance, potentiometer threshold, mechanical vibration, and electrical noise.
  • HTMLMediaElement.playbackRate changes both speed and perceived pitch according to browser behavior; this is not a time-stretching audio engine.

Troubleshooting

  • Confirm DO is connected to Arduino pin 9.
  • Confirm StandardFirmataPlus is installed and running.
  • Reconnect the USB port from the browser.
  • Check the Connection debug tab for Firmata pin-9 messages.

One rotation is counted twice

  • Adjust the module potentiometer in small increments.
  • Improve magnet alignment and mount rigidity.
  • Narrow the detection zone while preserving reliable full-speed detection.
  • Compare the duplicate interval with the 50 ms software filter.

Playback stops while the spinner is still moving

  • Confirm legitimate pulse gaps remain below the 225 ms stop timeout.
  • Move the sensor closer or retune the threshold if some rotations are missed.
  • Inspect the debug trace for missing LOW transitions.

RPM seems too high

  • Confirm there is only one accepted low transition per physical revolution.
  • Look for duplicate blinks on the module LED.
  • Remember that readings change in 60-RPM increments.

RPM changes but playback does not behave as expected

  • Open Speed bands and verify the thresholds.
  • Reset the bands to restore 0.5× / 1× / 2× defaults.
  • Browser audio playback rate is clamped to the supported 0.25×–2× range.