An openFrameworks application that generates stereo audio and synchronises an LED strobe light over WiFi/OSC. A modulation system lets any parameter be driven by built-in LFOs or incoming OSC values.
- Stereo audio synthesis with independent L/R waveforms and frequency
- Per-channel volume envelopes (looping, multiple waveform shapes, bezier editing)
- Modulation matrix: LFOs or external OSC inputs routable to any parameter with curve shaping
- WS2812 LED strobe control via OSC/UDP (Wemos D1 Mini firmware included)
- Keyboard-navigable GUI with live parameter bars
- JSON preset system with automatic preset transitions (threshold or time-based)
- Leap/blob tracker integration: spatial blob views, occupation / activity / stress accumulators
- openFrameworks ≥ 20260309 (gcc6 release build)
- ofxOsc addon (included with oF)
- Wemos D1 Mini (ESP8266)
- WS2812 LED strip on pin D7 (GPIO13)
- Arduino IDE with FastLED and ArduinoOSC (by hideakitai) libraries
cd apps/audioOutputExample
make
make RunReleasesrc/
ofApp.h — class declaration, all structs
ofApp.cpp — setup, update, draw, event handlers
ofApp_audio.cpp — audio synthesis, wave tables, volume envelopes
ofApp_mod.cpp — LFOs, mod matrix, OSC receiver
ofApp_strobe.cpp — OSC strobe sender
ofApp_gui.cpp — all drawing / GUI
ofApp_settings.cpp — JSON settings, preset management
wemos/
strobe_control/
strobe_control.ino — ESP8266 firmware
bin/data/
settings.json — all configuration and presets
"strobe": { "ip": "192.168.178.21", "port": 8000 },
"oscReceiver": { "port": 9000 }strobe— IP and UDP port of the Wemos D1 Mini running the strobe firmware.oscReceiver— UDP port the app listens on for incoming modulation values.
All sound and strobe parameters live inside presets. The active preset is selected with the keyboard UI. Multiple presets can coexist; saving with Shift+S creates a timestamped copy.
targetFrequency ──► Phase L ──► waveformL ──► volumeL × volumeEnvL ──► Left out
└──► Phase R ──► waveformR ──► volumeR × volumeEnvR ──► Right out
(freq × freqMultiplierR)
(+ phaseOffset lookup offset)
| Value | Shape |
|---|---|
| 0 | Sine |
| 1 | Square |
| 2 | Sawtooth |
| 3 | Bezier (drag control points on screen) |
| 4 | Triangle |
| 5 | Noise |
Each channel has a looping volume envelope independent of the audio frequency.
| Value | Shape |
|---|---|
| 0 | Static (fixed value) |
| 1 | Sine |
| 2 | Square |
| 3 | Sawtooth |
| 4 | Bezier (drag control points on screen) |
| 5 | Triangle |
volLoopSecL/R — loop length in seconds (0.1 – 60).
volEnvMinL/R, volEnvMaxL/R — remap envelope output to a sub-range (e.g. 0.2–0.8 keeps the volume from going silent).
Four LFOs (modLfo0 … modLfo3) run continuously. Each has:
| Parameter | Key | Description |
|---|---|---|
| Type | modLfo{n}Type |
0=static 1=sine 2=square 3=saw 4=triangle |
| Rate | modLfo{n}Rate |
Hz (0.01 – 20) |
| Static value | modLfo{n}Static |
Output when type = 0 |
External float values arriving on the receiver port are registered via oscInputs in the preset. Raw values are stored without remapping — all shaping is done per-route.
"oscInputs": [
{ "address": "/slider/1" },
{ "address": "/knob/freq", "argIndex": 1, "default": 0.5 }
]| Field | Description |
|---|---|
address |
OSC address to listen for |
argIndex |
Which argument of the OSC message to use (default 0) |
default |
Fallback value when no message has been received yet |
Routes connect a source (LFO or OSC input) to a target parameter. They live in modRoutes inside the preset and are applied in order. Edit settings.json directly to configure them.
LFO source:
{ "lfo": 0, "target": "volumeL", "depth": 1.0, "multiply": true }OSC source:
{
"oscAddress": "/lidar/closest/dist", "target": "volumeL", "depth": 1.0, "multiply": false,
"remapInMin": 0, "remapInMax": 3000, "remapOutMin": 0, "remapOutMax": 1, "remapCurve": "pow", "remapExponent": 0.5
}| Field | Description |
|---|---|
lfo |
LFO index 0–3 (used when oscAddress is absent) |
oscAddress |
OSC address string (takes priority over lfo) |
argIndex |
Which OSC argument to read (default 0) |
target |
Parameter name (any key from the keyboard UI) |
multiply |
true = base × value false = base + (value − 0.5) × 2 × depth |
depth |
Modulation depth for add mode |
remapInMin/Max |
Input range — maps raw source value to normalised [0,1] |
remapOutMin/Max |
Output range — maps normalised value to final output |
remapCurve |
Curve shaping: linear, sin, sqrt, quad, cubic, smoothstep, pow |
remapExponent |
Exponent for pow curve (default 2) |
Each route carries its own remap. The raw source value is normalised via remapInMin/Max, shaped by remapCurve, and scaled to remapOutMin/Max. This lets the same input drive multiple targets with independent ranges and curves. Default remap (0→1 linear) is a passthrough.
Modulated parameters are highlighted in cyan in the keyboard GUI. The bar shows the effective (post-modulation) position; a small o marks the base value.
The app receives spatial tracking data via OSC on the receiver port. Blob data is automatically exposed in the mod matrix under the following addresses:
| Address | Description |
|---|---|
/leap/stats arg 0/1 |
Occupation / activity (% of foreground / changed pixels) |
/leap/blob/<i>/position args 0/1/2 |
dist (m), height (m), angle (deg) |
/leap/blob/<i>/velocity args 0/1/2/3 |
vx, vy, vz (m/s), speed (m/s) |
Blobs are sorted by dist each frame. Only blobs present in the latest bundle are kept.
Three named blobs are derived each frame and available as mod route sources:
| Key prefix | Description |
|---|---|
/leap/active/closest/ |
Blob with smallest dist |
/leap/active/x/ |
Average of blobs on one angular side |
/leap/active/y/ |
Average of blobs on the opposite side |
Each exposes dist, height, angle, speed, vx, vy, vz as argIndex 0. Add an oscInputs entry with a matching address to apply remapping or a curve.
| oscInputValues key | Description |
|---|---|
/leap/occupation#0 |
Accumulates each frame, decays at leapOccupationDecay /s |
/leap/activity#0 |
Raw per-frame value |
/leap/stress#0 |
Sums activity over time, decays at leapStressDecay /s |
Decay rates are adjustable in the keyboard GUI under the leap section.
The app receives lidar cluster data via OSC on the receiver port (same port as Leap). All values are automatically exposed in the mod matrix.
| Address | Arguments | Description |
|---|---|---|
/lidar/clusters |
count (int) |
Number of tracked clusters in this bundle; resets the batch |
/lidar/cluster |
id (int), angle (float, deg), dist (float, mm), pointCount (int) |
One cluster per message |
/lidar/occupation |
value (float) |
Occupation metric (0–1) |
/lidar/activity |
value (float) |
Activity metric (0–1) |
Send /lidar/clusters first in each bundle, followed by one /lidar/cluster per cluster.
Clusters are sorted by distance each frame. The closest cluster is exposed as:
| Address | Description |
|---|---|
/lidar/closest/dist#0 |
Distance of closest cluster (mm, raw) |
/lidar/closest/angle#0 |
Angle of closest cluster (degrees) |
/lidar/closest/points#0 |
Point count of closest cluster |
/lidar/clusters#0 |
Total cluster count |
/lidar/occupation#0 |
Occupation value |
/lidar/activity#0 |
Activity value |
Add oscInputs entries with matching addresses to apply range remapping or curve shaping before the values reach mod routes. Example — map dist from 0–3 m to 0–1:
{ "address": "/lidar/closest/dist", "inMin": 0, "inMax": 3000, "outMin": 0, "outMax": 1 }A top-down polar view (500 × 500 px) is drawn below the Leap blob views. Each cluster is plotted at dist·sin(angle) / dist·cos(angle) (North = forward). Occupation and activity bars are shown along the bottom edge. Range: 5 m.
Each preset can define automatic transitions in a "transitions" array. The first matching condition triggers a switch.
"transitions": [
{
"condition": "stress",
"threshold": 15.0,
"target": "energetic",
"transitionDuration": 3.0
},
{
"condition": "time",
"duration": 60.0,
"target": ["calm", "default", "slow"],
"transitionDuration": 5.0
}
]| Field | Description |
|---|---|
condition |
"stress" triggers when stress ≥ threshold; "time" triggers after duration seconds |
threshold |
Stress value that fires the transition (stress condition) |
duration |
Seconds before switching (time condition) |
target |
Preset name string, or array of names for a random pick |
transitionDuration |
Seconds to crossfade all parameters from old to new preset (default 0 = instant) |
Stress resets to 0 on every preset load. A 1-second cooldown prevents cascading switches. During a crossfade all numeric parameters (volume, frequency, envelope ranges, strobe settings, mod depths, etc.) interpolate smoothly using a smoothstep curve.
The strobe frequency is targetFrequency × strobeRatio. Phase is derived from the audio oscillator so the strobe stays locked to the sound.
| Parameter | Description |
|---|---|
strobeRatio |
Strobe freq relative to audio freq |
strobeDutyRatio |
On-time fraction (0.01 – 1.0) |
strobePhaseUs |
Additional phase offset in microseconds |
strobeBrightness |
FastLED global brightness (0 – 255) |
LEDs are always full-white; the host sends fixed (255,255,255,sync=0) over the wire so the Wemos firmware needs no changes.
| Key | Action |
|---|---|
| ↑ / ↓ | Move cursor between parameters |
| ← / → | Decrease / increase value by one step |
| Shift + ← / → | Decrease / increase by large step |
s |
Save current preset |
Shift + S |
Save as new timestamped preset |
e |
Stop audio |
r |
Start audio |
Flash to a Wemos D1 Mini. Edit WIFI_SSID and WIFI_PASS at the top of the file. The device prints its IP address on boot via Serial (115200 baud) — set that IP as strobe.ip in settings.json.
OSC messages received on port 8000:
| Address | Arguments | Description |
|---|---|---|
/strobe/state |
onUs offUs syncPosUs r g b brightness sync |
Full state update (sent at 50 Hz by host) |
/strobe/status |
(none) | Print current settings to Serial |
The firmware uses a hardware timer (100 µs resolution) for strobe timing, so WiFi latency never delays transitions.