← Back to portfolio Français

snake_differ

Architecture overview — Puara module for the Seeed XIAO ESP32-S3 Sense

Purpose

A motion-detection firmware that watches a camera feed, computes frame-by-frame pixel differences, and broadcasts the results as OSC messages over WiFi — so a DAW, synthesizer, or generative environment can react to the presence, position, and shape of moving objects in the frame. It was built for tracking dark shapes (kinetic coil sculptures) on a light background, but works for any dark-on-light motion source.

Hardware

BoardSeeed XIAO ESP32-S3 Sense
CameraOV2640 (onboard)
MicrophonePDM (onboard, GPIO 41/42)
Motor outputsD2 = GPIO 3, D3 = GPIO 4 (PWM, 1 kHz, 10-bit)
ConnectionWiFi 2.4 GHz
PowerUSB-C

No wiring is required for the camera or mic; motor outputs need an external driver for anything beyond a small signal.

System overview

The camera and microphone feed the firmware, which either streams an OSC bundle out to receiving software, or serves a small web interface (with a live MJPEG preview) in a browser for calibrating the camera before a show. Both paths share the same WiFi connection and the same on-device settings store.

snake_differ system overview The onboard camera and microphone feed the ESP32-S3 firmware, which either streams an OSC bundle over WiFi to receiving software, or serves a web interface and MJPEG preview in a browser for calibration before a show. OV2640 camera + PDM mic Onboard, QVGA grayscale ~30 fps snake_differ firmware Puara module on ESP32-S3 OSC bundle out UDP, ~30 Hz Web UI + MJPEG preview Ports 80 / 81 Receiving software DAW, Max/MSP, Logic, Protokol Browser Config, scan, settings, preview Both paths share the same WiFi connection and settings storage
System overview: sensors → firmware → OSC output or calibration web UI

Firmware task structure

Internally, three concurrent pieces of code — two FreeRTOS tasks plus the Arduino main loop — share state through mutex-protected structs:

  1. Camera task (~30 fps) — for every frame, a single pixel-by-pixel pass computes two independent sets of metrics at once:
  2. Mic task (~100 Hz) — reads 512 PDM samples at 16 kHz, computes RMS, and applies a linear micGain clamped to 1.0.
  3. Main loop (~30 Hz) — reads both metric sets under their locks, applies latch behaviour (most spatial values hold their last known state when the scene goes still, rather than snapping to zero; velocity, motion flag, and microphone RMS are always live), then either:

The web server (port 80, from the Puara module) and the optional MJPEG snapshot server (port 81) run independently of this loop, reading the latest raw frame and current settings as needed.

snake_differ firmware task structure Two concurrent FreeRTOS tasks compute camera-derived and microphone-derived metrics into mutex-protected shared state. The main Arduino loop reads that state at about 30 Hz, applies latch behaviour, and either sends the OSC bundle out over UDP or, on incoming OSC, drives the motor PWM outputs. Camera task (~30 fps) Single-pass diff + pixel analysis Mic task (~100 Hz) PDM read, RMS with gain Shared metrics Mutex-protected diff/pixel/mic state Main loop (~30 Hz) Applies latch, builds OSC bundle OSC bundle out UDP to oscIP:oscPORT Incoming OSC threshold, motor1, motor2 Drives motor PWM (D2/D3)
Firmware task structure: concurrent tasks, shared state, and the main loop

Settings reference

All settings live on the Settings web page, apply immediately without reflashing, and persist across reboots.

ParameterDefaultNotes
oscIP192.168.4.2IP of the computer receiving OSC
oscPORT8000UDP port the receiver listens on
localPORT8000UDP port the device listens on for incoming OSC
diffThreshold30Pixel brightness delta (0–255) that counts as motion; lower = more sensitive
videoStream0Set to 1 to enable the MJPEG preview on port 81; disable during performance
camContrast0OV2640 hardware contrast, range -2 to 2; higher helps dark shapes stand out
micGain10Linear gain on raw mic RMS, clamped to 1.0; practical range 1–100
pixThreshold100Absolute brightness (0–255) below which a pixel counts as dark coil material

OSC reference

Outgoing bundle — ~30 Hz

Sent as a single OSC bundle to oscIP:oscPORT; always contains all messages below. Device name comes from config.json (default snake_differ_0).

AddressArgsLatchDescription
/<device>/motionmotion (int), magnitude (float)mag onlyCurrent motion state and fraction of frame moving
/<device>/bboxx_min, y_min, x_max, y_max (float×4)yesBounding box of motion, normalised 0–1
/<device>/centroidcx, cy (float×2)yesCentre of mass of moving pixels, normalised
/<device>/velocityvx, vy (float×2)noCentroid displacement per frame; zero when still
/<device>/spreadsx, sy (float×2)yesStd deviation of motion pixels around centroid
/<device>/fillfill (float)yesMotion density inside the bounding box
/<device>/micrms (float)noOnboard mic RMS, normalised 0–1, independent of visual motion
/<device>/coveragecoverage (float)noFraction of the whole frame below pixThreshold
/<device>/masscx, cy (float×2)noCentroid of all dark pixels, tracks coil mass even at rest
/<device>/grid/coverage24 floatsnoDark-pixel fraction per zone (6×4, row-major, row 0 = top)
/<device>/grid/density24 floatsnoEdge/gradient magnitude per zone — coil tightness
/<device>/grid/texture24 floatsnoLocal variance per zone — texture richness

Incoming messages

AddressArgDescription
/snake_differ/thresholdfraction (float)Updates diffThreshold live; value is a fraction of 255
/snake_differ/motor1speed (int 0–1000)PWM duty on D2 / GPIO 3
/snake_differ/motor2speed (int 0–1000)PWM duty on D3 / GPIO 4

First-time setup

1. Flash the firmware (PlatformIO)

pio run --target upload
pio run --target uploadfs

Requires the pioarduino platform (ESP32 Arduino 3.x) — the standard PlatformIO ESP32 2.x platform has a known crash in UDP receive; platformio.ini already points at the correct platform.

2. Connect to the device's WiFi

On first boot (or with no router configured), the device creates its own access point (SSID snake_differ_0, password garnetwillis).

3. Open the web interface

http://snake_differ_0.local (fallback: the AP's default IP, 192.168.4.1, or whatever the serial monitor shows). Config / Scan / Settings pages are all there.

4. Optionally join a studio router

On the Config page, enter router SSID/password and Save; the device connects on next reboot and the AP stays active as a fallback.

Calibration workflow

  1. Position the device so the camera sees the full area of interest.
  2. Set videoStream = 1, open Config, check framing.
  3. Adjust camContrast until the tracked shape reads clearly darker than the background.
  4. With the scene still, watch the serial monitor (pio device monitor) for continuous [diff] no motion.
  5. Move the shape — [diff] mag=... lines should appear immediately.
  6. Tune diffThreshold until detection is clean with minimal noise.
  7. Set videoStream = 0, enter oscIP/oscPORT, Save.
  8. Confirm the bundle arrives at ~30 Hz in Protokol or the intended receiver.

Receiving OSC

Camera preview

When videoStream = 1, an MJPEG stream is served at http://<device_ip>:81/stream (also embedded directly in the Config page) using a single persistent multipart HTTP connection — no polling, no per-frame round-trip. Preview is the same grayscale frame used for diff computation, and camContrast affects both. Turn videoStream off before a show — JPEG encoding runs continuously while a client is connected.

Technical notes

Built with