An in-depth guide detailing Haxel's multi-tasking FreeRTOS scheduling model, safety state machines, firmware API interfaces, and advanced mathematical expression scripting.
Haxel is designed to bridge the gap between abstract software concepts and high-performance physical haptics. Instead of treating haptic feedback as an arbitrary series of on/off motor commands, Haxel models tactile actuation as a continuous, mathematically-defined waveform stream.
The platform decouples visual interaction (handled in the client web UI) from real-time execution (processed locally on the ESP32 chip). By utilizing a local mathematical expression evaluator in firmware, users can develop, deploy, and evaluate complex haptic behaviors dynamically without needing to recompile or flash code.
To achieve low-latency audio processing alongside responsive network control, Haxel distributes its workloads across both cores of the ESP32 using a 4-task FreeRTOS scheduling architecture:
| Task Name | Core Pinned | Priority | Frequency | Functionality Description |
|---|---|---|---|---|
| engine_task | Core 1 | Priority 5 (RT) | 1,000 Hz | Evaluates pattern mathematics, processes envelopes, and commits duty cycles to the HAL driver. |
| audio_task | Core 1 | Priority 4 (RT) | DMA Driven | Collects I2S microphone samples, runs the 256-point FFT, and computes envelope statistics. |
| web_task | Core 0 | Priority 2 (Normal) | Asynchronous | Drives the captive portal DNS, handles standard HTTP API endpoints, and handles active WebSockets. |
| housekeeping | Core 0 | Priority 1 (Low) | 10 Hz | Manages mDNS broadcasting, WiFi status checks, system statistics, and writes configuration to LittleFS. |
Real-Time Isolation: All network operations (Wi-Fi stack, TCP/IP, WebSockets) are restricted to Core 0. This guarantees that network traffic spikes or socket re-connections cannot block the high-priority real-time tasks (Engine and Audio) on Core 1, ensuring jitter remains below 1.4 ms even under heavy web traffic.
When driving physical, inductive actuators like LRA and ERM motors, firmware safety is critical to prevent hardware damage due to bugs or thermal buildup. Haxel provides a built-in safety state machine:
Calling the function raiseFault(const char* code) locks the engine into the FAULT state. Haptics are instantly cut off and will not resume automatically under any circumstances.
To clear a fault, the user must resolve the hardware issue and send a {"clear": true} state patch via the Web API. Only after this explicit reset can the system be put back into PLAYING mode.
Haxel's expression engine runs math scripting expressions at 1 kHz in real time. Scripts have access to system variables and standard math helpers:
| Variable | Type | Units | Definition & Usage |
|---|---|---|---|
t / time |
float |
Seconds | Pattern time since boot, pre-multiplied by playback speed. Monotonic — wrap it yourself with % or frac() for cyclic patterns. |
dt / delta |
float |
Seconds | Time since the previous evaluation. One engine tick (0.001 s) on hardware, one animation frame (~0.016 s) in the browser simulator. Integrate with dt and both behave identically. |
freq |
float |
Hertz | Reserved frequency-shift parameter (currently a constant 150.0). |
speed |
float |
Scalar | The global playback speed coefficient (default is 1.0). |
intensity |
float |
0.0 to 1.0 | The master intensity slider value. Applied to your output automatically — read it only if you want non-linear responses. |
floor |
float |
0.0 to 1.0 | The motor startup-floor calibration value. Applied downstream by the engine; exposed for scripts that shape around it. |
PI / TAU |
const |
Radians | 3.14159… and 6.28318…. sin(t * TAU) is exactly one cycle per second. |
Persistent variables: any name you assign
(env = env + ...) survives between ticks and starts at 0. This is what makes envelopes,
filters, and physics possible — see the live examples in Section 05.
Because t is global, making patterns that are phase-independent or require persistent local accumulation
requires delta-time integration. The code snippets below demonstrate advanced script behaviors:
Integrates a changing rate into a phase so speed sweeps never click or jump:
// Accumulate phase dynamically using dt
rate = 2 + sin(t) * 1.5;
phase = frac(phase + dt * rate);
wave(phase)
Integrates state variables to low-pass filter noise, creating a resonant physical sweep:
// Low-pass filter loop via delta-time integration
q = 0.4;
f = 6 * dt;
bp = bp + f * (noise(t * 3) - lp - q * bp);
lp = lp + f * bp;
clamp(lp, 0, 1)
Models physical inertia by lagging a target signal at different rise/fall rates:
// Fast attack, slow release
target = square(t / 2, 0.5);
k = target > env ? 12 : 2.5;
env = env + (target - env) * min(1, dt * k);
env
Shapes smooth noise into organic, textured pulses using an exponential decay envelope:
// pow(2.718, -x) is e^-x — a natural decay curve
envelope = pow(2.718, -5 * (t % 1.5));
noise(t * 20) * envelope
Every one of these techniques — and every built-in function — is demonstrated with a live-rendered waveform in Section 05: Live Coding Examples below.
Every canvas below runs the code above it through the same expression compiler the firmware uses, in real time, right now. What you see scrolling past is exactly the motor power the ESP32 would output. Copy any snippet into the Pattern Studio, or use it as a starting point on the device portal.
The examples escalate: constants, then oscillators, then wave shaping, then rhythm and randomness,
then stateful dt patterns, then audio reactivity, and finally a full combination. One
rule holds throughout: layer one dominant voice over one quiet bed. Sum three loud
signals and skin stops reading them as anything — it all becomes white noise.
| Function | Returns | Description |
|---|---|---|
sin(x) cos(x) tan(x) | -1..1 (tan: ±∞) | Radians trig. tan() explodes near π/2 — always clamp() it. |
wave(x) | 0..1 | Haptics-friendly sine: one full 0→1→0 cycle per whole number of x. |
triangle(x) | 0..1 | Linear rise then fall, one cycle per whole number of x. |
square(x, duty) | 0 or 1 | On for the first duty fraction of each cycle (default 0.5). |
frac(x) | 0..1 | Fractional part — a free sawtooth ramp. |
abs(x) sqrt(x) pow(x, y) | varies | Rectify, fatten (sqrt), or sharpen (pow with y > 1) a wave. |
floor(x) ceil(x) round(x) | integer | Quantizers — turn ramps into staircases, times into beat indices. |
min(a, b) max(a, b) clamp(x, lo, hi) | varies | Limiters. max() is also the cleanest way to layer two voices. |
mix(a, b, k) / lerp(a, b, k) | varies | Blend from a to b as k goes 0→1. |
step(edge, x) | 0 or 1 | Hard gate: 1 when x ≥ edge. |
smoothstep(lo, hi, x) | 0..1 | Eased gate: fades from 0 to 1 between lo and hi. |
time(period) | 0..1 | A looping clock: ramps 0→1 every period seconds. |
random() | 0..1 | New value every tick — raw white noise. Always shape it. |
hash(n) | 0..1 | Deterministic pseudo-random: same n always gives the same value. |
noise(x) / perlin1d(x) | 0..1 | Smooth Perlin noise — organic drift instead of chaos. |
vu() peak() | 0..1 | Microphone volume envelope; peak() is vu() with a 1.2× boost. |
bass() mid() treble() | 0..1 | Averaged FFT energy in the low / middle / high bands. |
band(n) | 0..1 | A single FFT bin, n = 0 (deepest bass) … 31 (highest treble). |
beat() pitch() | 0..1 | Convenience helpers: a ready-made pulse train and a slowly wandering tone tracker. |
The ESP32 hosts a JSON REST API and a WebSocket server at ws:///ws. Clients can query
the status and send updates using raw JSON objects.
Returns the active status of the haptic engine, parameters, and segment lists:
Applies incremental updates to the active engine state. Unspecified parameters are preserved: