This application uses a fully modular plugin-based architecture. You can add new visual effects without modifying the main application code!
The application automatically discovers and loads effects from the effects/ directory. Here's how it works:
openlightshow/
├── src/
│ └── openlightshow/
│ ├── main.py # Main application (no effect code!)
│ ├── effect_base.py # Base Effect class
│ ├── effect_loader.py # Auto-discovery system
│ ├── presets.toml # Preset configurations
│ ├── exclude_list.toml # Effect conflict rules
│ ├── icons/ # Application icons
│ └── effects/ # Plugin directory
│ ├── __init__.py
│ ├── bullet_holes.py # Self-contained effect
│ ├── moving_gobos.py # Self-contained effect
│ └── ... (50+ effects)
├── pyproject.toml # Package configuration
└── README.md # Project documentation
Create a new .py file in the src/openlightshow/effects/ directory (e.g., src/openlightshow/effects/my_awesome_effect.py):
import random
import math
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QPainter, QColor, QPen
from ..effect_base import Effect
class MyAwesomeEffect(Effect):
"""
Brief description of what your effect does.
"""
name = "My Awesome Effect" # This will appear in the UI
effect_class = "effect_class1" # Classification for exclusion grouping
def __init__(self, size: QSize):
super().__init__(size)
# Initialize your effect state here
self.angle = 0.0
def on_beat(self):
"""Called when a beat is detected."""
super().on_beat() # This randomizes self.color
# Add beat-reactive behavior here
def update(self, dt_ms: int, energies: dict, sensitivity: float, flash_thresh: float):
"""Called every frame (~60 FPS)."""
# Update animation state
# energies contains: 'low', 'mid', 'high' (0.0-1.0)
self.angle += (dt_ms / 1000.0) * energies.get('high', 0.0) * sensitivity
def paint(self, p: QPainter, brightness: float):
"""Render your effect."""
p.save()
# Your drawing code here
w, h = self.size.width(), self.size.height()
cx, cy = w / 2, h / 2
color = QColor(self.color)
color.setAlphaF(brightness)
p.setPen(QPen(color, 3))
p.drawLine(int(cx), int(cy), int(cx + 100), int(cy))
p.restore()No, seriously. Just save the file and restart the application. Your effect will:
- ✅ Be automatically discovered
- ✅ Appear in the effects list
- ✅ Be available for selection
- ✅ Work with all existing features (weights, exclusion groups, presets)
Run the application to see your effect in action:
# For development install
source venv/bin/activate # On Linux/Mac
# or
venv\Scripts\activate # On Windows
openlightshow
# Or if installed from PyPI
pip install openlightshow
openlightshowIf you want to use an AI assistant (like Claude, ChatGPT, or GitHub Copilot) to generate effects, use this prompt:
Complete Prompt for AI:
You are extending a PySide6 (version 6.9.3) music-reactive lightshow application.
Context:
- The app has a base class `Effect` in `effect_base.py`:
class Effect:
name: str
effect_class: str
def __init__(self, size: QSize): ...
def resize(self, size: QSize): ...
def on_beat(self): ...
def update(self, dt_ms: int, energies: Dict[str,float], sensitivity: float, flash_thresh: float): ...
def paint(self, p: QPainter, brightness: float): ...
- Each effect is a subclass of `Effect` with a unique `name` string and an `effect_class` attribute for grouping.
- Lifecycle:
- `on_beat()` is called when a beat is detected.
- `update()` is called every frame (~60 FPS) with:
- `dt_ms`: elapsed time in milliseconds
- `energies`: dict with normalized 'low', 'mid', 'high' frequency bands (0.0-1.0)
- `sensitivity`: user-controlled multiplier
- `flash_thresh`: threshold for high-frequency strobe triggers
- `paint()` is called to render the effect using a `QPainter`.
- the method calls p.save() at the beginning
- the method calls p.restore() at the end
- The app automatically discovers effects from the effects/ directory.
- Constraints:
- At most 3 effects are active at once.
- When no music is playing, the lightshow area is black.
Your task:
- Implement a **new effect** as a subclass of `Effect` in a standalone file.
- Give it a descriptive `name` string.
- Assign an `effect_class` string (use a unique class like "effect_class43" or group with similar effects).
- Define its behavior in `on_beat`, `update`, and `paint`.
- Use `QPainter` drawing primitives (lines, rects, ellipses, arcs, fillRect, drawPoint, etc.).
- Ensure it respects the screen size (`self.size`) and stays within bounds.
- Make it visually distinct from existing effects.
- Drive visuals with `energies['low']`, `energies['mid']`, `energies['high']` and/or beats.
- Keep the background black (do not fill the entire screen except for intentional flashes).
Required imports:
```python
from ..effect_base import Effect
from PySide6.QtCore import QSize
from PySide6.QtGui import QPainter, QColor
# Add other imports as needed (QPen, random, math, etc.)
Output format:
- Provide the complete Python file content for the new effect.
- Include a docstring describing what the effect does.
- Save it as
src/openlightshow/effects/effect_name.py(snake_case filename).
**Then describe your effect idea:**
> "Create an effect called SpiralWave: a spiral of dots that expands and contracts with the bass, rotates with mid frequencies, and changes color on beats."
---
## 🎯 Available Methods & Properties
### Base Effect Class (`effect_base.Effect`)
**Attributes:**
- `name` (str): Effect name shown in UI (must be unique)
- `effect_class` (str): Classification attribute for grouping similar effects (prevents conflicts)
- `size` (QSize): Current rendering area size
- `color` (QColor): Current effect color (randomized on beats by default)
**Methods:**
- `__init__(self, size: QSize)`: Initialize effect
- `resize(self, size: QSize)`: Handle window resize
- `on_beat(self)`: React to detected beats (default: randomize color)
- `update(self, dt_ms, energies, sensitivity, flash_thresh)`: Update animation state
- `paint(self, p: QPainter, brightness: float)`: Render the effect
### Update Method Parameters
```python
def update(self, dt_ms: int, energies: Dict[str, float], sensitivity: float, flash_thresh: float):
pass
- dt_ms: Milliseconds since last frame
- energies:
{'low': 0.0-1.0, 'mid': 0.0-1.0, 'high': 0.0-1.0}low: Bass frequencies (20-200 Hz)mid: Mid frequencies (200-2000 Hz)high: Treble frequencies (2000-8000 Hz)
- sensitivity: User-controlled multiplier (0.0-2.0+)
- flash_thresh: Threshold for strobe detection (0.0-1.0)
Common drawing methods you can use in paint():
# Lines
p.drawLine(x1, y1, x2, y2)
p.drawPolyline(QPolygonF([point1, point2, ...]))
# Shapes
p.drawRect(x, y, width, height)
p.drawEllipse(center: QPointF, rx, ry)
p.drawPolygon(QPolygonF([...]))
p.drawArc(rect, startAngle, spanAngle)
# Filled shapes
p.fillRect(x, y, width, height, color)
# Points
p.drawPoint(x, y)
p.drawPoints([QPointF(...), ...])
# Styling
p.setPen(QPen(color, width))
p.setBrush(QBrush(color))
p.setCompositionMode(QPainter.CompositionMode_Plus) # Additive blending
p.setRenderHint(QPainter.Antialiasing, True)class PulsingCircle(Effect):
name = "Pulsing Circle"
effect_class = "effect_class43"
def __init__(self, size: QSize):
super().__init__(size)
self.radius = 50.0
def on_beat(self):
super().on_beat()
self.radius = 200.0 # Expand on beat
def update(self, dt_ms, energies, sensitivity, flash_thresh):
# Decay back to normal
self.radius = max(50.0, self.radius - (dt_ms * 0.2))
def paint(self, p: QPainter, brightness: float):
p.save()
w, h = self.size.width(), self.size.height()
color = QColor(self.color)
color.setAlphaF(brightness)
p.setPen(QPen(color, 3))
p.drawEllipse(QPointF(w/2, h/2), self.radius, self.radius)
p.restore()class FrequencyBars(Effect):
name = "Frequency Bars"
effect_class = "effect_class44"
def update(self, dt_ms, energies, sensitivity, flash_thresh):
# Store energies for painting
self.bass = energies.get('low', 0.0) * sensitivity
self.mid = energies.get('mid', 0.0) * sensitivity
self.high = energies.get('high', 0.0) * sensitivity
def paint(self, p: QPainter, brightness: float):
p.save()
w, h = self.size.width(), self.size.height()
# Draw three bars
bar_width = w / 3
p.fillRect(0, h - h*self.bass, bar_width, h*self.bass, QColor(255, 0, 0))
p.fillRect(bar_width, h - h*self.mid, bar_width, h*self.mid, QColor(0, 255, 0))
p.fillRect(2*bar_width, h - h*self.high, bar_width, h*self.high, QColor(0, 0, 255))
p.restore()If your effect needs special settings (like the Starfield spacing), add a custom method:
class MyEffect(Effect):
name = "My Effect"
effect_class = "effect_class45"
def __init__(self, size: QSize):
super().__init__(size)
self.custom_param = 50
def set_custom_param(self, value: int):
self.custom_param = valueThen in src/openlightshow/main.py, add handling in set_controls() (this is the only place you'd need to modify the main script):
def set_controls(self, ...):
# ...
for e in self.effects_all:
if e.name == "My Effect" and hasattr(e, 'set_custom_param'):
e.set_custom_param(value)Effects are automatically prevented from playing together based on conflict rules defined in exclude_list.toml. The system ensures visual diversity by preventing similar effects from running simultaneously.
How it works:
- Each effect has an
effect_classattribute (e.g.,"centereffect_class01","scanner_class02", etc.) - The
exclude_list.tomlfile defines which effect classes cannot play together - When selecting active effects, the system automatically excludes conflicting effects
- If a conflict occurs, the system randomly replaces one of the conflicting effects with a non-conflicting effect
- This happens automatically based on the configuration!
Assigning effect classes:
When creating a new effect, assign it an appropriate effect_class:
class MyNewEffect(Effect):
name = "My New Effect"
effect_class = "unique_class01" # Use a unique class, or group with similar effectsGuidelines:
- Use the same
effect_classfor effects that should never play together (similar visual patterns) - Use different
effect_classvalues for effects that complement each other - Check
exclude_list.tomlto see existing conflict rules - Add your effect class to the exclusion rules if needed
- Currently, the system manages 50+ effects with various classes
Example:
# These effects should not play together (same scanning pattern)
class RadarSweep(Effect):
name = "Radar Sweep"
effect_class = "effect_class29" # Scanner effects
class ScannerWindmill(Effect):
name = "Scanner Windmill"
effect_class = "effect_class33" # Different scanner pattern
# These can play together (different visual patterns)
class WavingLine(Effect):
name = "Waving Line"
effect_class = "effect_class2" # Line effects
class PixelExplosion(Effect):
name = "Pixel Explosion"
effect_class = "effect_class25" # Particle effects- Visual Test: Run the app and enable only your effect to see it in isolation
- Auto-discovery Test: Verify the effect appears in the effects list when you restart the application
- Integration Test: Enable it with other effects to check compatibility
- Audio Reactivity Test: Play music and verify the effect responds to bass, mids, highs, and beats
- Always call
p.save()andp.restore()inpaint()to avoid affecting other effects - Keep background black - don't fill the entire screen unless that's the effect
- Use normalized coordinates when possible (0.0-1.0) and multiply by size
- Respect brightness - multiply alpha/colors by the brightness parameter
- Handle resize gracefully - don't assume fixed dimensions
- Use beat debouncing if your effect creates objects on beats (see BulletHoles)
- Test with different sensitivity values - effects should work from 0.1 to 2.0+
- Limit the number of objects (e.g., max 100 particles)
- Use
QPen.setCosmetic(True)for lines that shouldn't scale - Avoid expensive operations in
paint()- do calculations inupdate() - Use integer coordinates for
drawLine(),drawPoint(), etc. - Consider using
QPolygonFfor multiple connected lines
The beauty of this architecture is its simplicity:
- ✅ No registration needed - Just drop the file in
src/openlightshow/effects/ - ✅ No imports in main script - Auto-discovery handles everything
- ✅ Self-contained - Each effect is independent
- ✅ Safe to experiment - Can't break other effects
- ✅ Package ready - Your effect will be included when building the package
If you've created custom effects and want to install the package:
# Development install (editable mode)
pip install -e .
# Or build and install the package
pip install build
python -m build
pip install dist/openlightshow-*.whlHappy effect creation! 🎉