OpenGL-based DMX lighting control system with real-time GPU shader effects, audio-reactive visuals, weather simulation, and a web control panel. Designed for live event visualization and art installations.
- Quick Start
- Configuration
- Weather System
- Web Control Panel
- MIDI Controller
- Event Map System
- Troubleshooting
- Developer Reference
# First-time setup: creates venv, installs deps, launches app
./bin/setup_and_run.sh # Linux/macOS
bin\setup_and_run.bat # Windows (double-click or run in cmd)
# Quick launch after first setup
./bin/quick_run.sh # Linux/macOS
bin\quick_run.bat # WindowsOn Linux, make scripts executable first:
chmod +x bin/setup_and_run.sh bin/quick_run.sh# Activate virtual environment
source venv/bin/activate # Linux/macOS
source venv/Scripts/activate # Windows/bash
# Run the main application
python Stories_OGL.pyOnce running, the web control panel is at http://localhost:5000.
Install or reinstall at any time:
pip install -r requirements.txtKey packages: glfw, PyOpenGL, numpy, scipy, opencv-python, sounddevice, librosa, soundfile, flask, sacn, pygame, numba, pyglm, scikit-image, zeroconf.
- Python 3.8+ (3.10+ recommended)
- Windows 10/11, Ubuntu/Debian, or macOS
- OpenGL 3.3+ compatible GPU (OpenGL ES 3.1 for Raspberry Pi)
- Audio input device (optional, for audio-reactive effects)
All main settings are in Stories_OGL.py:
| Lines | Setting |
|---|---|
| 22–25 | Frame dimensions and magnification |
| 40 | Audio input device name |
| 49 | Enable/disable web control |
| 57 | Web control port (default 5000) |
| 59 | Admin password |
| 639 | Initial weather set |
| 640 | Initial weather state |
DMX universes and fixtures: edit files in config/.
Disable the render window: set self.scheduler.state["simulate"] = False in Stories_OGL.py.
If DMX receivers update at a very slow frame rate, the network send buffer may be too small. Add to /etc/sysctl.conf:
net.core.wmem_max=16777216
net.core.wmem_default=16777216
If you get PortAudio errors:
sudo apt-get install portaudio19-dev| Set | States | Season Speed | Transition Speed | Vibe |
|---|---|---|---|---|
| Peaceful Forest (default) | clear, light_rain, foggy, firefly, mushroom, bloom, leaves | 1.0x (30 min/yr) | 0.8x (~5 min) | Gentle, natural |
| Storm World | windy_night, heavy_rain, thunderstorm, foggy, spooky | 1.5x (20 min/yr) | 1.5x (~2.7 min) | Intense, dramatic |
| Desert Realm | clear, sandstorm, volcano, windy_night | 0.5x (60 min/yr) | 0.6x (~6.7 min) | Harsh, alien |
| Ethereal Mist | heavy_fog, foggy, spooky, mushroom, firefly | 0.7x (43 min/yr) | 0.5x (~8 min) | Mysterious |
| Cosmic Night | clear, asteroid, windy_night | 2.0x (15 min/yr) | 2.0x (~2 min) | Celestial |
| Full Spectrum | All 15 states | 1.0x | 1.0x | Maximum variety |
- season_speed: How fast the 30-minute year cycle runs.
0.5x= 60 min/yr,1.0x= 30 min/yr,2.0x= 15 min/yr. - season_extremity: How much seasons bias weather transitions.
0.5x= subtle,1.0x= normal,2.0x= extreme. - transition_speed: How often weather changes.
0.5x= ~8 min,1.0x= ~4 min,2.0x= ~2 min.
Each frame (~30 fps): P(transition) = (1/800) × weather.Switch_rate × set.transition_speed
On transition: if a set change is pending, the new set activates and a random state from it is chosen. Otherwise, a weighted-random state from the current set is picked (weights influenced by season_extremity).
Each set only transitions between its own states. If a state's normal transitions include states outside the current set, those are filtered out. If no valid transitions remain, a random state from the set is chosen.
- Open
http://localhost:5000/weather_sets(orhttp://glsimple.local:5000/weather_sets) - Click any weather set card — it queues the change
- On the next weather transition (up to ~4 min), the system switches sets
Edit lib/weather_params.py:
WEATHER_SETS = {
"your_set_name": {
"name": "Display Name",
"description": "What makes this set special",
"states": ["clear", "foggy", "spooky"],
"season_speed": 1.0,
"season_extremity": 1.0,
"transition_speed": 1.0,
"background_events": ["clouds", "stars"], # Continuous effects
"allowed_parameters": ["wind_speed", "fog", "starryness", ...],
},
}Then update web/templates/weather_sets.html to add its icon and description card.
Common parameters for each weather state in WEATHER_PRESETS:
| Parameter | Range | Description |
|---|---|---|
wind_speed |
0.0–2.0 | Wind intensity |
rain_rate |
0.0–1.0 | Rainfall amount |
fog |
0.0–1.5 | Fog density |
fog_color |
[R, G, B] | Fog RGB color (0.0–1.0 each) |
starryness |
0.0–1.0 | Star visibility |
firefly_density |
0.0–2.0 | Firefly count multiplier |
ambient_sound |
filename | Audio file from media/sounds/ |
possible_transitions |
string array | State IDs this can transition to |
transition_weights |
number array | Probability weights per transition |
on_transition_events |
list of tuples | Events triggered when this state activates |
A visual browser-based editor is available at http://localhost:5000/weather_editor.
Workflow:
- Select a set from the left panel
- Click a weather state tab to edit its parameters
- Click Validate to check for errors before saving
- Click Save Changes — this overwrites
lib/weather_params.py(a backup is created at.backup) - Restart the application for changes to take effect
Creating a new weather state:
- Click + New Weather State
- Name uses
UPPERCASE_SNAKE_CASE(e.g.,MYSTIC_RAIN) - Value uses
lowercase(e.g.,mystic_rain)
Allowed Parameters per Set:
Each set has an allowed_parameters list — only those parameters appear in the editor for that set. This keeps the UI focused. You can add/remove parameters with the + Add Parameter button, or create entirely new custom parameters (name, type, default value).
Parameter types: Number, Text, Array (3-element RGB), Array of Strings, Array of Numbers.
Editor API endpoints:
GET /api/weather_editor/all_dataPOST /api/weather_editor/validatePOST /api/weather_editor/save
Access at http://localhost:5000 (or from another device on your network at http://YOUR_IP:5000).
The server listens on 0.0.0.0 by default. To restrict to localhost, change web_controller.py to host='127.0.0.1'.
- Weather Intensity (0.0–2.0): Effect multiplier
- Fog Strength (0.0–1.0): Fog density
- Rain Amount (0.0–1.0): Rain intensity
- Audio Sensitivity (0.1–3.0): Mic input scaling
- Enable Fireflies (checkbox)
- Enable Stars (checkbox)
- Color Mode (dropdown)
- Effect Speed (0.1–5.0)
env_system.web_controller.add_control(
key="my_param",
control_type="slider", # "slider", "checkbox", or "select"
label="My Parameter",
min=0, max=100, step=1, default=50
)
# Checkbox
web_controller.add_control("my_toggle", "checkbox", "Enable Feature", default=True)
# Dropdown
web_controller.add_control("my_select", "select", "Choose Option",
options=["option1", "option2", "option3"], default="option1")intensity = env_system.web_controls.get('weather_intensity', 1.0)
if env_system.web_controls.get('enable_fireflies', True):
# firefly logicGET /— Control panel HTMLGET /api/schema— Control definitionsGET /api/values— Current control valuesPOST /api/update— Update a single valuePOST /api/batch_update— Update multiple values
The server runs in a background thread and updates the shared web_controls dict, which the main loop reads each frame.
Supports the Korg nanoKontrol2 via pygame MIDI. Requires pygame (included in requirements.txt).
Channel: 1 2 3 4 5 6 7 8
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
Knobs: │ ◯ │ ◯ │ ◯ │ ◯ │ ◯ │ ◯ │ ◯ │ ◯ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
Sliders: │ | │ | │ | │ | │ | │ | │ | │ | │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
S buttons: │ S │ S │ S │ S │ S │ S │ S │ S │
M buttons: │ M │ M │ M │ M │ M │ M │ M │ M │
R buttons: │ R │ R │ R │ R │ R │ R │ R │ R │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Transport: ⏮ ⏭ ↻ ● ◄ ■ ► ●
- Knobs:
knob_1–knob_8→ float 0.0–1.0 - Sliders:
slider_1–slider_8→ float 0.0–1.0 - Channel buttons:
s_button_1–8,m_button_1–8,r_button_1–8→ bool - Transport:
track_prev,track_next,cycle,marker_set,marker_prev,marker_next,rewind,forward,stop,play,record→ bool
from lib.midi_controller import KorgNanoKontrol2
midi = KorgNanoKontrol2(auto_connect=True)
midi.start_reading() # Background thread, ~1000 Hz poll
# Read values
knob1 = midi.get_knob(1) # 0.0–1.0
slider3 = midi.get_slider(3) # 0.0–1.0
s1 = midi.get_button('s', 1) # True/False
# Or manually each frame (instead of background thread)
changes = midi.update() # Returns dict of changed controlsdef on_slider1(name, value):
scheduler.state['fog_strength'] = value
midi.register_callback('slider_1', on_slider1)def setup_weather_controls(midi, env_system):
def on_button(weather_state):
def cb(name, pressed):
if pressed:
env_system.transition_to_weather(weather_state, 5.0)
return cb
midi.register_callback('marker_set', on_button(WeatherState.CLEAR))
midi.register_callback('marker_prev', on_button(WeatherState.RAIN))
midi.register_callback('cycle', on_button(WeatherState.SANDSTORM))Device not found:
midi = KorgNanoKontrol2(auto_connect=False)
midi.list_devices() # Shows all available MIDI devices
midi.device_name = "nano" # Try partial name match
midi.connect()Button values stuck: Some buttons send Note On instead of CC. If buttons aren't responding, enter setup mode by holding CYCLE + TRACK on power-on.
Test standalone:
python lib/midi_controller.py
python tools/midi_integration_example.pyThere are two categories of sound and two mechanisms for playing them:
| Short (< ~30 s) | Long (voice, music) | |
|---|---|---|
| Ambient | Loop via StreamingPlayer |
Loop via StreamingPlayer |
| Event-triggered | play_oneshot() |
StreamingPlayer(loop=False) |
Handled automatically by transition_to_weather() via StreamingPlayer. Defined per weather state in WEATHER_PRESETS as ambient_sound filename. Fade in/out on transition is built in.
Non-blocking fire-and-forget. The file loads in a background thread on first call; subsequent calls with the same file are served from cache instantly.
soundengine = scheduler.state["soundengine"]
soundengine.play_oneshot(Path("media/sounds/thunder.wav"), volume=1.0)To wire a short sound into the event map (so it can be scheduled like a shader effect):
# In EnvironmentalSystem.__init__, add to self.event_map:
"thunder_crack": lambda: (audio_thunder_crack, {"volume": 1.0})
# Wrapper function (same pattern as shader effects):
def audio_thunder_crack(state, outstate, volume=1.0):
if state['count'] == 0:
outstate['soundengine'].play_oneshot(
Path("media/sounds/thunder.wav"), volume=volume
)
# no cleanup needed — oneshot manages itselfThen reference it in a weather state's on_transition_events or in random_events() like any other event.
Streams from disk, never fully loaded into memory. Interruptible: the scheduler's cleanup hook (count == -1) fires when the scene changes, giving the sound a chance to fade out gracefully.
def audio_storm_narration(state, outstate, filepath="narration.wav", volume=1.0):
if state['count'] == 0:
player = StreamingPlayer(
engine=outstate['soundengine'],
filepath=Path("media/sounds") / filepath,
name=f"event_{id(state)}",
loop=False,
volume=volume,
fade_in=1.0,
)
player.start()
state['player'] = player
if state['count'] == -1: # scene changed or event expired
state['player'].fade_out(2.0)Register and schedule exactly like a shader effect:
"storm_narration": lambda: (audio_storm_narration, {"filepath": "storm_voice.wav"})
# In weather preset:
"on_transition_events": [("storm_narration", 60, 0)]WAV, FLAC, OGG Vorbis, MP3 — all streamed chunk-by-chunk via miniaudio for StreamingPlayer. play_oneshot() uses soundfile (WAV/FLAC) or librosa (MP3) and caches the result in RAM.
Events are defined by name in a central map and referenced by name in weather configuration — no hardcoded if chains needed.
self.event_map = {
# Simple event
"stars": lambda: fx.shader_stars,
# Event with parameters
"firefly": lambda: (fx.shader_firefly, {"squish_top_width": 0.1}),
"falling_leaves": lambda: (fx.shader_falling_leaves, {"squish_top_width": self.scale}),
"fog": lambda: (fx.shader_fog, {"strength": 0.0, "color": (0.7, 0.7, 0.8)}),
}Run continuously (effectively forever) while a weather set is active. Defined in the set config:
"peaceful_forest": {
"background_events": ["clouds", "firefly", "stars"],
...
}When a set activates, all existing events are cancelled and the background events are scheduled.
Triggered when a specific weather state becomes active. Defined per state in WEATHER_PRESETS:
WeatherState.SANDSTORM: {
"on_transition_events": [("sandstorm_event", 100, 0)],
# Format: (event_name, duration_seconds, frame_id)
}
# Multiple events:
WeatherState.THUNDERSTORM: {
"on_transition_events": [
("lightning_event", 120, 0),
("heavy_rain_particles", 150, 0),
("thunder_rumble", 90, 1), # Secondary display
],
}Events run for their specified duration and are automatically cleaned up.
-
Add to event_map in
EnvironmentalSystem.__init__():"my_effect": lambda: (fx.shader_my_effect, {"intensity": 0.8}),
-
Reference by name in set config (background) or weather preset (on-transition):
"background_events": ["clouds", "my_effect"] # or "on_transition_events": [("my_effect", 60, 0)]
No changes to transition_to_weather() needed.
# Schedule directly from code
self._schedule_event_from_map("fog_beings", start_time=0, duration=60, frame_id=0)- Keep background events to 2–5 per set
- On-transition events are temporary and can be more intensive
- Use
frame_idto distribute load across multiple displays
Ensure Python is installed and on PATH. Verify with python --version. Restart terminal after installing.
Run pip install -r requirements.txt with the venv activated. Or re-run bin\setup_and_run.bat / ./bin/setup_and_run.sh.
- Run
sound_editor.pyto list available devices - Update the device name at line 40 of
Stories_OGL.py - Remove the
device_nameparameter to use the system default
sudo apt-get install portaudio19-devUpdate graphics drivers. GPU must support OpenGL 3.3+ (or OpenGL ES 3.1 on Raspberry Pi).
- Close other GPU-intensive applications
- Reduce magnification in
Stories_OGL.py(lines 22–25) - Set
enable_web_control = Falseto disable Flask overhead
Change the port at line 57 of Stories_OGL.py, or close whatever is using 5000.
- Click Validate first — fix any reported errors
- Ensure write permissions on
lib/ - Restore from backup:
lib/weather_params.py.backup
- Check browser console (F12) for JS errors
- Verify Flask is still running in the terminal
- Ensure control keys match between schema and application code
Network send buffer too small. Add to /etc/sysctl.conf:
net.core.wmem_max=16777216
net.core.wmem_default=16777216
Check terminal for "Weather set change queued". The change applies on the next weather transition (up to ~4 min). The web UI shows a PENDING badge while waiting.
- Create a new file in
renderer/effects/that extendsShaderEffectfrombase.py - Name the wrapper function with
shader_prefix and the class withEffectsuffix — they are auto-discovered - See
docs/shader_info.txtfor the full guide covering: depth/alpha blending system, horizontal wrapping, event wrapper pattern, fade in/out, audio reactivity, and a complete template
Stories_OGL.py # Entry point — wires everything together
lib/
ambient_audio.py # Cross-fade ambient track controller
audio_analyzer.py # Microphone capture and frequency analysis
audio_engine.py # Audio playback engine (streaming + one-shot)
dmx_sender.py # sACN/E1.31 DMX pixel sender
event_scheduler.py # Pure timed event queue and shared state dict
midi_controller.py # Korg nanoKontrol2 MIDI integration
weather_params.py # Weather states, presets, and set configs
weather_set.py # Active set, event map, and per-set config access
weather_state.py # State interpolation and seasonal transitions
renderer/
shader_renderer.py # GLFW window + OpenGL rendering loop
effects/ # 40+ individual shader effect modules
base.py # ShaderEffect base class
engine/
render_pipeline.py # Hardware integration: renderer + audio + DMX + per-frame loop
web/
web_controller.py # Flask web control panel
templates/ # Flask HTML templates
config/ # DMX universe and fixture definitions (Unit*.txt)
tools/ # Standalone utilities: sound_editor, midi_integration_example, etc.
media/sounds/ # Ambient audio files
media/images/ # Image assets
docs/ # Documentation
bin/ # Setup and launch scripts
Updated at 40 FPS. Contains:
raw_bands:(1000, 32)— raw power per frequency bandnorm_short: normalized to ~0.25s average (use for beat detection)norm_long: normalized to ~2.5s average (use for gradual changes)norm_long_relu: ReLU of norm_long (highlights peaks above baseline)band_centers:(32,)— center frequency of each band (40 Hz – 16 kHz)
Frequency ranges: Bass [0:8], Mids [8:20], Highs [20:32].
docs/CYBERPUNK_EVENTS.md— Planned effects and events for the Cyberpunk weather setdocs/shader_info.txt— Full shader development guide with templates