Architecture overview — Puara module for the Seeed XIAO ESP32-S3 Sense
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.
| Board | Seeed XIAO ESP32-S3 Sense |
| Camera | OV2640 (onboard) |
| Microphone | PDM (onboard, GPIO 41/42) |
| Motor outputs | D2 = GPIO 3, D3 = GPIO 4 (PWM, 1 kHz, 10-bit) |
| Connection | WiFi 2.4 GHz |
| Power | USB-C |
No wiring is required for the camera or mic; motor outputs need an external driver for anything beyond a small signal.
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.
Internally, three concurrent pieces of code — two FreeRTOS tasks plus the Arduino main loop — share state through mutex-protected structs:
diffThreshold count as "in motion." From those pixels it derives a bounding box,
centroid, frame-to-frame velocity, spread (standard deviation around the centroid), fill ratio (motion
density inside the bounding box), and overall magnitude (fraction of the frame moving).pixThreshold counts as "dark coil material." This gives a global coverage fraction and
centroid of dark mass, plus three values per zone across a 6×4 grid of 24 zones: dark-pixel coverage,
edge/gradient density (a proxy for how tightly the coils are wound), and local brightness variance
(texture richness).micGain clamped to 1.0.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.
All settings live on the Settings web page, apply immediately without reflashing, and persist across reboots.
| Parameter | Default | Notes |
|---|---|---|
oscIP | 192.168.4.2 | IP of the computer receiving OSC |
oscPORT | 8000 | UDP port the receiver listens on |
localPORT | 8000 | UDP port the device listens on for incoming OSC |
diffThreshold | 30 | Pixel brightness delta (0–255) that counts as motion; lower = more sensitive |
videoStream | 0 | Set to 1 to enable the MJPEG preview on port 81; disable during performance |
camContrast | 0 | OV2640 hardware contrast, range -2 to 2; higher helps dark shapes stand out |
micGain | 10 | Linear gain on raw mic RMS, clamped to 1.0; practical range 1–100 |
pixThreshold | 100 | Absolute brightness (0–255) below which a pixel counts as dark coil material |
Sent as a single OSC bundle to oscIP:oscPORT; always contains all messages below. Device
name comes from config.json (default snake_differ_0).
| Address | Args | Latch | Description |
|---|---|---|---|
/<device>/motion | motion (int), magnitude (float) | mag only | Current motion state and fraction of frame moving |
/<device>/bbox | x_min, y_min, x_max, y_max (float×4) | yes | Bounding box of motion, normalised 0–1 |
/<device>/centroid | cx, cy (float×2) | yes | Centre of mass of moving pixels, normalised |
/<device>/velocity | vx, vy (float×2) | no | Centroid displacement per frame; zero when still |
/<device>/spread | sx, sy (float×2) | yes | Std deviation of motion pixels around centroid |
/<device>/fill | fill (float) | yes | Motion density inside the bounding box |
/<device>/mic | rms (float) | no | Onboard mic RMS, normalised 0–1, independent of visual motion |
/<device>/coverage | coverage (float) | no | Fraction of the whole frame below pixThreshold |
/<device>/mass | cx, cy (float×2) | no | Centroid of all dark pixels, tracks coil mass even at rest |
/<device>/grid/coverage | 24 floats | no | Dark-pixel fraction per zone (6×4, row-major, row 0 = top) |
/<device>/grid/density | 24 floats | no | Edge/gradient magnitude per zone — coil tightness |
/<device>/grid/texture | 24 floats | no | Local variance per zone — texture richness |
| Address | Arg | Description |
|---|---|---|
/snake_differ/threshold | fraction (float) | Updates diffThreshold live; value is a fraction of 255 |
/snake_differ/motor1 | speed (int 0–1000) | PWM duty on D2 / GPIO 3 |
/snake_differ/motor2 | speed (int 0–1000) | PWM duty on D3 / GPIO 4 |
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.
On first boot (or with no router configured), the device creates its own access point (SSID
snake_differ_0, password garnetwillis).
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.
On the Config page, enter router SSID/password and Save; the device connects on next reboot and the AP stays active as a fallback.
videoStream = 1, open Config, check framing.camContrast until the tracked shape reads clearly darker than the background.pio device monitor) for continuous
[diff] no motion.[diff] mag=... lines should appear immediately.diffThreshold until detection is clean with minimal noise.videoStream = 0, enter oscIP/oscPORT, Save.udpreceive 8000 → OSC-route /<device>/motion
/<device>/bbox ... → unpack per message.
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.
/stream), only active when
videoStream = 1pioarduino (ESP32 Arduino 3.x) required