Owner: Studio Documentation Status: active Last updated: 2026-07-07
- Architecture Overview
- Repository Layout
- Core Subsystems
- Writing a New Effect
- Stable Public Contracts
- Registering Help Hotkeys via HELP_ENTRIES
- GLSL Conventions
- Data Flow Diagram
- Thread Topology & Module Boundary Contract
- Test Strategy
- Adding Platform Support
- Release Checklist
┌──────────────────────────────────────────────────────────────────┐
│ App (app.py) │
│ SDL2 window ─── moderngl context ─── fixed-timestep main loop │
│ │ │ │
│ HotkeyHandler ◄── SDL events Overlays (HUD) │
│ MidiManager ◄── rtmidi thread │
│ │ │ │
│ Playlist ──► effect_class ──► BaseEffect ──► render() │
│ │ │
│ AudioManager ──────────────► update(dt, AudioData) │
└──────────────────────────────────────────────────────────────────┘
The main loop runs at 60 fps with a fixed-timestep accumulator capped at 100 ms (spiral-of-death prevention). Every frame:
- SDL events are polled → dispatched to
HotkeyHandler. AudioManager.get_audio_data()returns the latestAudioDatasnapshot.effect.update(dt, audio)advances effect state.effect.render()draws to the screen (or to an FBO during transitions).Overlays.render(dt)draws the HUD on top.SDL_GL_SwapWindowflips the buffer.
unicorn-viz/
├── assets/
│ ├── ansi/ Hand-crafted .ANS demo files
│ │ └── acid/ Downloaded ACiD Productions art
│ ├── fonts/ Optional font8x16.bin (CP437 8×16 VGA font)
│ └── icons/ unicorn-viz.png / .ico application icon
├── docs/
│ ├── user-guide.md
│ ├── developer-guide.md
│ ├── configuration.md
│ └── effect-settings.md
├── drop-ins/ Each folder is a git submodule
│ ├── alien-invasion-01/
│ ├── cyber-war-01/
│ ├── disco-ball-01/
│ ├── hacker-terminal-01/
│ ├── multi-head-01/ (subsystem: display controller)
│ ├── textures-01/
│ ├── tron-grid-01/
│ ├── unicorn-tears-01/
│ └── webcam-01/
├── tools/
│ ├── generate_ansi_art.py Generates the hand-crafted .ANS files
│ ├── fetch_acid_ans.py Downloads real ACiD art from 16colo.rs
│ ├── profiling/ perf_frames.py (Perf frame log summary),
│ │ mixer_store_bench.py, projectm_bench.py
│ └── launchers/ Linux .sh/.desktop, Windows .bat/.ps1
├── unicornviz/
│ ├── __init__.py
│ ├── __main__.py
│ ├── app.py Main application class
│ ├── config.py TOML config loader
│ ├── dropins.py Drop-in loader utility
│ ├── hotkeys.py Keyboard + MIDI → action dispatcher
│ ├── midi.py MidiManager (python-rtmidi wrapper)
│ ├── overlays.py On-screen HUD rendering
│ ├── playlist.py Effect playlist management
│ ├── recording.py ffmpeg recording pipeline
│ ├── splash.py Animated splash screen
│ ├── audio/
│ │ ├── analyzer.py FFT + beat detector
│ │ ├── capture.py sounddevice PipeWire/ALSA capture
│ │ └── manager.py Thread-safe audio bridge
│ ├── ansi/
│ │ ├── loader.py ANSI escape parser + SAUCE reader
│ │ ├── font.py CP437 8×16 font atlas builder
│ │ └── renderer.py Canvas → RGBA OpenGL texture
│ └── effects/
│ ├── base.py BaseEffect ABC + AudioData
│ ├── registry.py Auto-discovery of effect subclasses
│ ├── wavey_gravy.py → Wavey Gravy
│ ├── ansi_viewer.py → ANSI Viewer
│ ├── audio_spectrum.py → Audio Spectrum
│ ├── copper_bars.py → Copper Bars
│ ├── cosmos.py → Cosmos
│ ├── rainbow_trance.py→ Rainbow Trance
│ ├── cube_3d.py → 3D Cube
│ ├── dali.py → Dali
│ ├── escher.py → Escher
│ ├── fire_lifelike.py → Fire
│ ├── fractal_zoom.py → Fractal Zoom
│ ├── metaballs.py → Metaballs
│ ├── particle_storm.py → Particle Storm
│ ├── plasma.py → Plasma
│ ├── audio_sine.py → Sine Scroller 2.0
│ ├── starfield.py → Starfield
│ ├── system_monitor.py → System Monitor
│ ├── tunnel.py → Tunnel
│ ├── van_gogh.py → Van Gogh
│ ├── vector.py → Vector
│ └── (+ drop-in effects loaded at runtime via dropins.py)
├── config.toml
├── requirements.txt
└── run.sh
unicornviz/app.py — class App
Key methods:
| Method | Purpose |
|---|---|
run() |
Initialises subsystems, enters main loop, tears down on exit |
_init_sdl() |
Creates SDL2 window (Wayland-first, X11 fallback) |
_init_moderngl() |
Creates OpenGL 3.3 core context via moderngl.create_context() |
_switch_effect(cls) |
Instantiates a new effect and starts a transition |
_render() |
Routes rendering through the transition FBO system |
goto_effect(cls) |
Public — called by HotkeyHandler and tests |
toggle_fullscreen() |
Calls SDL_SetWindowFullscreen |
toggle_pause() |
Freezes dt accumulation |
_on_resize(w, h) |
Updates viewport; propagates to active effects and overlays |
set_camera_layout(token) |
Delegates to always-on WebcamSystem subsystem (webcam-01) |
scale_pip(delta) |
Adjusts PiP scale in always-on WebcamSystem subsystem |
set_active_webcam_camera(id) |
Direct camera selection by device id |
set_webcam_camera_enabled(id, enabled) |
Enable/disable detected webcam devices |
set_webcam_brightness()/set_webcam_contrast() |
Webcam image-control setters |
set_webcam_flip_horizontal()/set_webcam_flip_vertical() |
Webcam flip-control setters |
get_runtime_state()/set_runtime_state() |
Shared global runtime-state read/write helpers |
All mutable state that should survive a restart is persisted through a single
shared RuntimeStateStore at runtime/global_state.json. The store is
JSON-backed, atomic-write (write-to-.tmp then os.replace()), and
thread-safe via threading.RLock.
API surface for drop-ins — always go through VJApi:
# Read (dotted path, with safe default)
value = vj_api.get_runtime_state('banner.font_px', default=28)
# Write (persists immediately)
vj_api.set_runtime_state('banner.font_px', 32)Namespace conventions
| Namespace prefix | Owner |
|---|---|
audio.* |
core AudioCapture — selected and viable audio source keys |
banner.* |
drop-in banner-01 — text, max line length, alpha, font, flags |
webcam.* |
drop-in webcam-01 — camera selection, per-camera settings |
multihead.* |
drop-in control-room-01 — monitor editor exclude list |
State isolation rules
- Core code reads and writes via
App.get_runtime_state()/App.set_runtime_state(). - Drop-ins must use
vj_api.get_runtime_state()/vj_api.set_runtime_state()— never construct their ownRuntimeStateStoreinstance. - Each drop-in must use its own top-level namespace key (e.g.
banner.*,webcam.*) and must never read or write another drop-in's keys. - ProjectM is explicitly exempt:
projectm-01manages its own state inruntime/projectm/anddark_excluded.txtunder its own directory. Do not attempt to move these into the global store. - Spotify is explicitly exempt: the token file
runtime/spotify-pro-token.jsonis a credential and must never be co-mingled with runtime/UI state.
Gitignore rules
The runtime/ directory is gitignored at the repo root and in every drop-in
that generates runtime files. Do not commit global_state.json or any other
runtime state file. Per-machine state is local-only by design.
Transient runtime state that is NOT persisted (lives only in memory):
- AutoVJ session decisions — written to
logs/autovj-*.jsonlas append-only telemetry; never needs to survive a restart. - Effect
self.parameters— reset to defaults on each effect switch by design. - Grand Finale phase state — intentionally transient.
- Keystroke log — append-only diagnostic log.
Backup / restore: copy runtime/global_state.json to back up all operator
preferences in one file. On a fresh machine, drop it back into runtime/
before launching.
A file that fails to load is never overwritten (unicornviz/safe_store.py; used by
global_state.json, config_profiles.json and presets.json). If the file cannot be read, is not
valid JSON, or has the wrong shape, it is renamed to <name>.corrupt-<timestamp> with its bytes
untouched, an ERROR says where it went, and the store starts from defaults. Saves then go to the
original path, which is safe because the quarantine copy exists. If the file cannot even be moved
or copied aside (read-only directory, permissions), the store blocks saves to that path for the
session and says so once. To recover, repair the .corrupt-* file and put it back. A file that
loads but contains entries the store will drop (a profile that is not an object) is copied aside
the same way before the next save.
Transitions are FBO-based: both the outgoing and incoming effects render
into separate FBOs, then a transition shader composites them to the screen
over transition_duration seconds. Supported modes: crossfade,
smoothfade, scanwipe, scanwipe_x, scanwipe_y, dissolve,
zoomblend, shuffle, random.
See Writing a New Effect below.
BaseEffect provides:
self.ctx— themoderngl.Contextself.width,self.height— current viewport sizeself.config— the per-effect config dict fromconfig.tomlself.time— monotonically increasing seconds since effect startself.parameters— dict of runtime-tweakable floats (exposed to MIDI)_fullscreen_quad()→(VAO, VBO)for shader-based fullscreen effects_make_program(vert, frag)→ compiledmoderngl.Program
Auto-discovery: registry.py uses importlib to import every .py in
the package directory, then inspects for non-abstract BaseEffect subclasses.
No registration table to maintain.
sounddevice callback
│ float32 mono PCM chunks (thread)
▼
AudioCapture._ring_buffer (deque, lock-protected)
│ get_latest_pcm()
▼
Analyzer.process(pcm)
│ FFT → smoothed spectrum → spectral flux → beat
▼
AudioData snapshot ──► effect.update(dt, audio)
Beat detection uses spectral flux onset: the RMS of positive differences between consecutive FFT frames is compared against a rolling mean + threshold k·σ. A 200 ms cooldown prevents double-triggers.
Thread safety: AudioCapture uses a threading.Lock on its ring buffer.
AudioManager.get_audio_data() is called from the main thread and returns a
consistent snapshot.
Reader thread shutdown: the background reader loop polls
stream.read_available and only calls the blocking stream.read(blocksize)
once enough frames are buffered (sleeping _READER_POLL_INTERVAL_S = 10 ms
otherwise, re-checking _stop_event each iteration). This is a deliberate
workaround, not an optimization: on the ALSA hostapi, stream.abort()
(Pa_AbortStream) does not reliably interrupt a read() call already
blocked inside PortAudio, so calling abort() from the main thread during
shutdown could leave the reader thread stuck in a foreign read() while the
stream it references is being torn down — the crash this previously caused
was an ALSA mmap/buffer error at quit, not a hang. Polling bounds the
reader's unresponsive window to one poll interval regardless of whether the
audio source ever produces data, so abort()/close() only run once the
reader has actually exited. Do not revert to an unconditional blocking
read() in this loop.
raw .ANS bytes
│
ANSIParser.parse()
│ ─ strips SAUCE record
│ ─ walks ESC[ sequences (SGR / CUP / cursor movement)
│ ─ interprets CP437 printable bytes
▼
ANSICanvas (grid of Cell(codepoint, fg, bg))
│
canvas_to_texture(ctx, canvas, font_atlas)
│ ─ for each cell: blit glyph pixels in fg/bg CGA colour
▼
moderngl.Texture (RGBA, canvas.width×8 × canvas.height×16 px)
│
ANSIViewer effect ─ CRT shader (barrel, scanlines, phosphor glow)
Font atlas: font.py builds a 2048×16 single-channel texture (all 256
CP437 glyphs side-by-side, 8 px wide each). If assets/fonts/font8x16.bin
exists (raw 256×16 bytes, MSB-first), it is used verbatim; otherwise a
built-in Python glyph table covering all block/box-drawing characters is used.
SAUCE: The SAUCE record is a 128-byte footer (plus 1-byte \x1a SUB
prefix) at the very end of the file. Fields read: title, author, group,
width, height. The viewer uses width/height to constrain the canvas, then
falls back to 80×25 if they are zero.
unicornviz/midi.py — class MidiManager
- Opens the first available
rtmidi.MidiInport matchingdevice_hint. - Registers a C-level callback that fires on the
rtmidiinternal thread. - Callback converts raw status bytes into typed
MidiEventdataclasses. - Distributes events to registered listener callables (thread-safe via a lock).
HotkeyHandler.attach_midi(midi) registers itself as a listener and maps
CC → effect.parameters[*] and Note → same actions as keyboard hotkeys.
The midi-controllers-01 drop-in extends core MIDI with full-surface LED
feedback for the Akai APC mini mk2. APCLedFeedback runs an action-centric
LED state machine: each mapped pad lights with an idle/active colour pair
derived from current VJState.
Host-mode SysEx — the APC ships in standalone mode and ignores NoteOn LED
commands until it receives a specific SysEx packet (_APC_HOST_MODE_SYSEX).
startup() sends this automatically. On reconnect the flag clears so it is
re-sent before the next colour push.
rawmidi bypass — LED output goes through libasound snd_rawmidi_open
directly to the APC Notes port output substream (hw:{card},0,1), bypassing
the ALSA sequencer. This is necessary because rtmidi.MidiOut via the
sequencer is the fallback path and it fails silently on kernel 7.0+ (see
below).
snd_ump regression (kernel 7.0+, Fedora 44) — snd_usb_audio wraps all
USB MIDI 1.0 devices in the snd_ump UMP compatibility bridge
(Type: Legacy in /proc/asound/card*/midi0). The bridge's output
pipeline is broken: bytes are accepted and the Tx counter increments, but
nothing reaches the USB endpoint. This affects the sequencer path AND the
rawmidi path once the bridge is activated by any input subscription. Fix:
echo "options snd_usb_audio midi2_enable=N" \
| sudo tee /etc/modprobe.d/snd-usb-midi2.conf
sudo reboot # module parameter is read-only at runtimeThe _check_snd_ump_output_broken() function detects the broken bridge at
startup and logs a warning with the exact fix commands.
See the drop-in's Integration doc and Troubleshooting doc for the full technical breakdown.
Config (config.py): Deep-merges config.toml over _DEFAULTS. All
access via cfg.get("section", "key", default=x) — never raises.
Validation lifecycle:
python -m unicornvizcallsregister_dropin_config_validators()first, thencfg.validate()before app startup.- Built-in config sections are validated for type compatibility and key constraints (enum/range/list element checks).
- Validation failures raise
ConfigValidationErrorand stop startup with a consolidated, operator-readable message.
Drop-in config validators:
- A drop-in can provide
drop-ins/<name>/config_validator.py. - Export callable:
validate_config(config_data) -> list[str] | None. - Errors returned there are automatically namespaced as
dropin:<name>: ...and merged into the same startup validation output.
Playlist (playlist.py): Wraps a list[Type[BaseEffect]]. advance()
is sequential or random depending on mode. Supports a pinned sequence from
config. All mutations happen on the main thread.
Overlays (overlays.py): Renders text HUD elements (effect name, help
panel, message toasts) using a bitmap font shader. Each overlay element has
an alpha timer so it fades out automatically. flash_name() and
flash_message() are the primary public API.
Help icon art is loaded from assets/icons/help/76px/ on sub-4K windows and
assets/icons/help/152px/ on 4K-or-larger windows. The loader must use the
source PNGs directly without vertical flipping or extra resizing; the authored
orientation is already correct, and the login/logout slot intentionally swaps
between login.png and logout.png based on auth state. Rail ordering keeps
auth controls on the right edge (... -> settings -> account -> login/logout);
account is currently shown unconditionally with an in-code TODO to auth-gate
it in a follow-up.
# unicornviz/effects/my_ripple.py
"""
A simple ripple-wave fullscreen effect.
"""
from __future__ import annotations
import moderngl
from unicornviz.effects.base import BaseEffect, AudioData
_VERT = """
#version 330
in vec2 in_vert;
out vec2 v_uv;
void main() { v_uv = in_vert * 0.5 + 0.5; gl_Position = vec4(in_vert, 0, 1); }
"""
_FRAG = """
#version 330
uniform float iTime;
uniform float iBass;
in vec2 v_uv;
out vec4 fragColor;
void main() {
float r = length(v_uv - 0.5);
float wave = sin(r * 30.0 - iTime * 4.0 + iBass * 8.0);
fragColor = vec4(vec3(0.5 + 0.5 * wave), 1.0);
}
"""
class MyRipple(BaseEffect):
NAME = "My Ripple"
AUTHOR = "yourhandle"
TAGS = ["audio"]
def _init(self) -> None:
self._prog = self._make_program(_VERT, _FRAG)
self._vao, self._vbo = self._fullscreen_quad()
self._bass = 0.0
def update(self, dt: float, audio: AudioData) -> None:
super().update(dt, audio) # ticks self.time
self._bass = audio.bass
def render(self) -> None:
self._prog["iTime"].value = self.time
self._prog["iBass"].value = self._bass
self._vao.render(moderngl.TRIANGLE_STRIP)
def destroy(self) -> None:
self._vao.release()
self._vbo.release()
self._prog.release()That is all. The effect is auto-discovered and appears in the playlist immediately.
Declare runtime-tweakable values in self.parameters, reading defaults from
self.config so operators can tune each effect in config.toml without
touching code:
def _init(self) -> None:
self.parameters = {
"speed": float(self.config.get("speed", 1.0)),
"zoom": float(self.config.get("zoom", 1.0)),
"glow": 0.6, # internal-only, no config override needed
}Canonical parameter names — use these exact keys when the concept applies:
| Key | Type | Hotkeys | MIDI default | Notes |
|---|---|---|---|---|
speed |
float | + / - / Ctrl+G reset |
CC74 | Scales animation rate |
zoom |
float | Alt+[ / Alt+] / Alt+Z random |
CC75 | View scale multiplier |
+/- keys scale speed; MIDI CC74 also maps to speed by default.
Per-effect random range overrides — operators can constrain the F6/F7
random-speed and Alt+Z random-zoom ranges on a per-effect basis in
config.toml. These keys are read automatically by the app; no effect code
is needed:
[effects.MyEffect]
speed = 1.0
zoom = 1.0
random_speed_min = 0.75 # F6 random-speed lower bound for this effect
random_speed_max = 1.50 # F6 random-speed upper bound for this effect
random_zoom_min = 0.80 # Alt+Z random-zoom lower bound
random_zoom_max = 1.20 # Alt+Z random-zoom upper boundIf these keys are absent, the global [hotkeys] defaults apply.
The following interfaces are treated as stable runtime contracts for drop-ins and effect modules. Changes should be backward compatible whenever possible.
unicornviz.effects.base.BaseEffectunicornviz.effects.base.AudioDataunicornviz.vj_api.VJApiunicornviz.config.Config
Notes:
- Effect drop-ins should import
BaseEffectandAudioDatafromunicornviz.effects.baseonly. - System drop-ins should interact with app runtime through
app.vj_apiinstead of private app attributes. VJApi.VERSION(backed byVJ_API_VERSION) is the compatibility signal for drop-ins that need to enforce a minimum API surface.
Drop-ins can publish hotkeys into the H help overlay by exposing a module- or
class-level HELP_ENTRIES collection.
Accepted entry format:
- Tuple form:
(section, key, description) - Dict form:
{"section": str, "key": str, "description": str}
Example:
HELP_ENTRIES = [
('Auto VJ', 'Ctrl+Alt+J', 'Toggle Auto VJ on/off'),
('Auto VJ', 'Ctrl+J then P', 'Toggle ping-pong mode'),
]Guidelines:
- Keep section names human-readable; they are grouped/sorted in the help UI.
- Keep key labels concise and consistent with runtime hotkey notation.
- Do not mutate help entries at runtime unless the section truly changes.
- The help overlay paginates into tabs of at most
Overlays.HELP_SECTIONS_PER_TAB(10) section headings each; a fully-loaded drop-in directory spans multiple tabs (PageUp/PageDown) rather than overflowing a single screen. The help overlay checks PageUp/PageDown ahead of registered drop-in key handlers (HotkeyHandler.handle()) so it isn't shadowed by a drop-in claiming the same keys globally. - A section is never split across multiple top-level cards. If a section has
more than
Overlays.HELP_MAX_ITEMS_PER_SECTION(7) entries, its items (not the section) paginate into an inner accordion — "Items 1-7", "8-14", ... — with exactly one item-page open at a time. Expanding a section opens page 0 and shows a "press Enter to see more items" hint under the title when it has more than one page; clicking a page header (or pressing Enter again) cycles to the next page. Moving focus to another section auto-closes any open multi-page accordion. Do not add a competing "split into multiple sections" mechanism — that approach was tried and explicitly rejected in favor of the in-section accordion. - The help overlay's right pane is itself tabbed (
Effectsalways present;Post FX/Mouseappear only when a drop-in has registered entries for them via_postfx_help_entries/_mouse_help_entries). This is separate from the left pane's per-tab section pagination above.
Discovery path:
- Startup calls
discover_dropin_help_entries()fromunicornviz/dropins.py. - Entries are forwarded to
Overlays.register_help_entries(). - Malformed entries are skipped safely; they should still be fixed promptly.
Any registered subsystem (or one of the app's own controller attributes)
joins the c configuration editor by implementing two methods; core
discovers it through the subsystem registry, so no core edit is needed.
class MyController:
CONFIG_EDITOR_CATEGORY = 'Visuals' # default tab for its rows
CONFIG_EDITOR_KEY = 'my_dropin' # persistence prefix
CONFIG_EDITOR_TITLE = 'My drop-in' # default section header
def config_editor_settings(self) -> list[dict]:
return [
{'name': 'glow', 'value': self.glow, 'min': 0.0, 'max': 1.0,
'hint': 'How much the frame glows'},
{'name': 'preview_fps', 'label': 'Preview fps cap', 'tab': 'Performance',
'kind': 'choice', 'choices': ('OFF', '10', '30', '60'),
'value': self.cap_index, 'hint': 'GPU readback ceiling'},
{'name': 'workers', 'tab': 'Performance', 'kind': 'choice',
'choices': ('2', '4', '8'), 'value': self.worker_index,
'restart': 'my_dropin'}, # a next-launch setting
]
def set_config_setting(self, name: str, value: float):
... # apply live; a 'restart' row returns {config_key: value}Tabs (2026-09-24): Performance is for cost knobs only (frame rates,
worker counts, caches, re-raster toggles); System for how the app meets
the machine (display, MIDI, audio engine, folders and paths, accounts);
Streaming for RTMP, video out and stream chat; Visuals / Audio
for the look (profile-backed). A drop-in may file its rows under a tab of
its own (CONFIG_EDITOR_CATEGORY = 'Auto VJ'); the tab appears while the
drop-in is loaded and follows the machine -- add it to
App._MACHINE_LIVE_TABS and Overlays._CE_MACHINE_TABS.
Row keys: name (setter key), value, min/max (sliders), and
optionally tab, kind (slider | toggle | choice), choices,
label, display, hint (tooltip + inline on the selected row),
badge, section, step, restart, config.
kind may also be 'text' or 'secret' (a string value; the setter
receives the typed string; add placeholder for an empty field). A secret
is masked with a SHOW toggle and never pre-filled while hidden. Give text
rows restart (or put them on a machine tab) so they persist.
config ('<section>.<key>') names the config.toml line the row replaces.
config.toml is being retired in favor of the menu (owner, 2026-09-24): at
the end of startup core copies a value the file actually sets (never a
built-in default) into the row's saved setting, once, if nothing is saved
yet (config_overrides.<section>.<key> for restart rows, the row's
current value as perf_dropin.<KEY>.<name> for live machine-tab rows).
From then on the menu wins and edits to that line do nothing. Every new
row for a setting that exists in config.toml should declare it.
Persistence is core's job:
- Audio / Visuals rows are saved in the active configuration
profile (
runtime/config_profiles.json) underdropin.<KEY>.<name>. There is always an active profile ("default" on first run); every change writes through to it (debounced ~0.4 s, flushed at shutdown), and it is loaded at the end of startup, so a row persists with no explicit save. Per-effect parameter edits on the Effects tab ride along the same way. - Machine-tab rows (live rows on Performance, Logging, System,
Streaming or Auto VJ --
App._MACHINE_LIVE_TABS) are remembered in runtime state asperf_dropin.<KEY>.<name>and replayed throughset_config_settingat the end of the next startup. A live row that moves from a profile tab to a machine tab keeps the value the active profile saved for it (carried over once at startup). restartrows call the setter, take its return value (a{config_key: value}dict, or the raw value undername) and lay it over the loaded config in memory, remembering it asconfig_overrides.<section>.<key>so the next launch applies it before the drop-in is built.config.tomlitself is never written.
For live runtime code, private fields are owner-module implementation details, not a general extension surface.
Preferred call order:
app.vj_apifor drop-ins and system controllers- public
Appmethods/properties for core cross-module callers - public
Overlaysmethods/properties for HUD/selector state
Policy:
- Drop-ins should not read or mutate
app._...state directly when the same behavior can be expressed throughVJApi. - Cross-module core callers (for example
hotkeys.py) should not reach into unrelated modules' underscore fields when a thin public wrapper onApporOverlayswould do. # noqa: SLF001should be limited to the owner modules that intentionally define and centralize runtime state, chieflyunicornviz/app.pyandunicornviz/vj_api.py.
Rule of thumb: if you need the same underscore-field access pattern in more than one caller, promote it to a public shim immediately instead of copying it.
VJApi compatibility is versioned. Runtime consumers can check either
unicornviz.vj_api.VJ_API_VERSION or app.vj_api.VERSION before using newer
capabilities.
Examples:
- good:
app.vj_api.flash_message(...) - good:
app.reset_render_scale() - good:
overlays.name_overlay_visible - avoid in new code:
app._overlays.flash_message(...) - avoid in new code:
app._running = False - avoid in new code:
app._current_effect.parameters['zoom'] = ...
| Attribute | Type | Required | Purpose |
|---|---|---|---|
NAME |
str |
Yes | Display name in overlays and playlist |
AUTHOR |
str |
No | Credit shown in help overlay |
TAGS |
list[str] |
No | Used for future filtering; no current effect |
PING_PONG_FRIENDS |
list[str] |
No | Preferred partner effect NAMEs for Auto VJ/manual ping-pong pairing |
Recommended tag values: "classic", "futuristic", "audio", "ansi",
"3d", "psychedelic", "particles", "visualizer".
PING_PONG_FRIENDS should contain effect display names (NAME values), not
class names. Example:
PING_PONG_FRIENDS = ['Kaleidoscope', 'Psychedelic', 'Unicorn Tears']Every visual effect must produce a visually distinct appearance each activation.
BaseEffect provides for free:
self.rng— per-instance seeded RNGself.time— starts in [0, 10000) so time-driven shaders vary automatically
Effects must randomize in _init():
- Colour/palette offset, starting angle/rotation, discrete mode, intensity/density, media indices
Exceptions: audio_spectrum.py, system_monitor.py (diagnostic).
Rule of thumb: If two back-to-back runs look identical for 3 seconds, add self.rng calls to _init().
All GLSL shaders follow these conventions:
#version 330 // OpenGL 3.3 core — no ARB extensions
// Uniforms — camelCase with 'i' prefix (Shadertoy-compatible where sensible)
uniform float iTime;
uniform vec2 iResolution;
uniform float iBass;
uniform float iBeat;
// Vertex input — snake_case with 'in_' prefix
in vec2 in_vert;
in vec2 in_pos;
in float in_life;
// Varyings — snake_case with 'v_' prefix
out vec2 v_uv;
// Fragment output — always named fragColor
out vec4 fragColor;Use double / dvec2 only where precision is truly required (i.e.,
FractalZoom). Double precision is slower on many consumer GPUs.
Avoid discard in performance-critical per-pixel paths; use alpha blending.
Use glslangValidator to catch shader syntax errors before runtime.
Fedora install:
sudo dnf install -y glslangValidate all embedded shaders in core effects and drop-ins:
/home/jj/Repos/unicorn-viz/.venv/bin/python tools/validate_glsl.pyValidate a narrower scope (example: one drop-in):
/home/jj/Repos/unicorn-viz/.venv/bin/python tools/validate_glsl.py drop-ins/america-250-01What the script does:
- Scans Python files for embedded GLSL string literals containing
#version. - Infers shader stage from symbol names like
_VERT,_FRAG, and keyword args such asvertex_shader=/fragment_shader=. - Runs
glslangValidatoron each extracted shader. - Reports totals for scanned files, shaders found, validated shaders, skipped unknown-stage shaders, and failures.
Exit codes:
0: all validated shaders passed1: one or more shader validations failed2: validator binary missing (glslangValidatornot onPATH)
┌─────────────────────────────────────────┐
│ MAIN THREAD │
│ │
config.toml ──► Config ──► App.__init__ │
│ │
SDL2 window + moderngl ctx │
│ │
┌───────────────┼───────────────┐ │
│ │ │ │
AudioManager MidiManager Overlays │
│ │ │ │
│ callback │ callback │ │
│ (sounddevice │ (rtmidi │ │
PipeWire ───► thread) │ thread) │ │
│ │ │ │
└───────────────┴───────────────┘ │
│ poll / get │
▼ │
main loop │
├── SDL_PollEvent │
├── HotkeyHandler.handle() │
├── audio_manager.get_audio_data()│
├── effect.update(dt, audio) │
├── effect.render() │
└── overlays.render(dt) │
│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ MAIN THREAD (SDL2 event loop + GL + effect pipeline) │
│ │
│ SDL_PollEvent ─► HotkeyHandler ─► App actions │
│ AudioManager.get_audio_data() → AudioData snapshot (read-only) │
│ effect.update(dt, audio) ──► effect.render() │
│ Overlays.render(dt) │
│ SDL_GL_SwapWindow │
│ │
│ ┌─ Recording write queue ──────────────────────────────────────┐ │
│ │ uv-rec-writer (daemon thread) │ │
│ │ Drains a queue[bytes] written by the main thread each frame │ │
│ │ Writes to disk via ffmpeg stdin pipe; never touches GL. │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ uv-audio-reader (daemon thread — AudioCapture) │
│ │
│ sounddevice.InputStream.read() blocking-read loop │
│ → mono-mixes stereo, writes mono PCM block to _block_queue │
│ → increments block_seq (atomic int) on each new block │
│ → fires _new_block_event (threading.Event) │
│ → tracks xrun_count for operator diagnostics │
│ GIL is released during the native read(); audio never competes │
│ with the render loop for Python bytecode execution. │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ uv-audio-analyzer (daemon thread — AudioManager) │
│ │
│ Waits on _new_block_event; skips if block_seq unchanged (dedup). │
│ Runs FFT → spectral flux → beat detection on the new block. │
│ Double-buffers result: writes to back AudioData, then atomically │
│ swaps front ↔ back (lock held only during pointer swap, not FFT). │
│ Appends onset events to a thread-safe queue for main-thread drain. │
│ Never touches moderngl or SDL objects. │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ rtmidi callback thread (MidiManager — optional) │
│ │
│ Fires on MIDI hardware events (note on/off, CC). │
│ Only appends to a queue or writes through a lock — never touches │
│ moderngl objects or Python data structures without synchronization.│
│ Main thread drains the queue each frame in HotkeyHandler.handle().│
└─────────────────────────────────────────────────────────────────────┘
| Rule | Rationale |
|---|---|
| moderngl objects are main-thread-only | No GL context on audio/MIDI threads |
AudioData snapshots are read-only once published |
Prevents races with in-flight renders |
| MIDI callbacks only write to queues | rtmidi fires on its own thread |
RuntimeStateStore uses threading.RLock for all reads/writes |
Safe from any thread |
Recording bytes are passed via queue.Queue |
Writer thread never reads GL state |
OpenBLAS is pinned to one thread (OPENBLAS_NUM_THREADS=1, set in unicornviz/__main__.py before the first numpy import) |
OpenBLAS otherwise spawns a worker per core at numpy import and those workers busy-wait after every call, taking CPU from the audio, render and mixer-analysis threads (2026-09-16). Nothing in-process needs a threaded BLAS; the Demucs subprocess sets its own counts. An exported value is kept (setdefault). |
The unicornviz/ core package must never hard-import a drop-in. All
drop-in symbols are loaded at runtime via unicornviz.dropins.load_dropin_symbol()
which uses importlib.util.spec_from_file_location. This is enforced by
tests/test_dropin_boundary.py.
unicornviz/app.py
└── unicornviz/dropins.py ← ONLY file allowed to reference drop-in paths
└── importlib.util.spec_from_file_location()
└── drop-ins/<name>/<module>.py (loaded at runtime, optional)
Every drop-in integration in app.py follows this pattern:
def _load_foo_class():
return load_dropin_symbol('drop-ins/foo-01/foo.py', 'FooController')
try:
FooController = _load_foo_class()
except Exception:
FooController = None # app starts normally without the drop-inNull/fallback controller classes are required for every optional drop-in so
that app.py can instantiate a no-op object without branching on None in
the hot path. Null contracts are enforced by tests/test_null_controller_contracts.py.
The project uses pytest with a baseline suite under tests/.
Current baseline coverage includes:
- audio fallback state-machine behavior (
tests/test_audio_capture_fallback.py) - VJ API safety when optional PostFX drop-in is absent (
tests/test_vj_api_postfx.py) - audio startup/fallback defaults (
tests/test_config_audio_defaults.py)
Run all tests:
/home/jj/Repos/unicorn-viz/.venv/bin/python -m pytestRun one module:
/home/jj/Repos/unicorn-viz/.venv/bin/python -m pytest tests/test_audio_capture_fallback.pyInstall pre-commit hooks (pytest runs before every commit):
/home/jj/Repos/unicorn-viz/.venv/bin/python -m pip install pre-commit
/home/jj/Repos/unicorn-viz/.venv/bin/pre-commit installDetailed workflow and policy are in Testing Guide.
Recommended expansion approach:
# tests/test_ansi_loader.py
from unicornviz.ansi.loader import ANSIParser
from pathlib import Path
def test_parses_sauce_title():
raw = Path("assets/ansi/acid/acid-56_GS-ACID.ANS").read_bytes()
canvas = ANSIParser().parse(raw)
assert getattr(canvas, "_sauce", {}).get("title") == "Ghengis' Final ANSI"Audio, config, playlist, and ANSI subsystems are all GL-free and can be unit
tested without a display. Use pytest with pytest-cov.
Use an offscreen moderngl context:
import moderngl
ctx = moderngl.create_standalone_context()
from unicornviz.effects.plasma import Plasma
effect = Plasma(ctx, 320, 240, {})
effect.render()
ctx.release()Test gate is enforced via local pre-commit hooks in this repository.
Use pre-commit run --all-files to run the same checks on demand.
Every agent seat works in its own git worktree, not in the shared
checkout. pre-commit stashes a tree's unstaged changes while its hooks
run, so in one shared checkout a seat's hook would stash -- and, on a
collision, lose -- another seat's uncommitted work, revert config.toml
under a live app, and fail on files the committer never touched
(2026-09-13: three lost commits, a stashed batch, a reverted config and
two collided live runs in one afternoon). A worktree per seat gives each
its own index and working tree; hooks then only ever see the committing
seat's files.
tools/seat_worktree.sh core # creates ~/Repos/unicorn-viz-seats/core on branch seat/core
tools/seat_worktree.sh --list # every worktree the repository knows aboutThe script is idempotent (re-run it to refresh submodules after a pointer
bump). A seat gets: a worktree on seat/<name> cut from origin/master;
every drop-in submodule cloned from the main checkout's local object store
(seconds, no network) and checked out at the pinned commit on its own
branch (never detached); a .venv symlink to the main checkout's venv,
because the hooks run .venv/bin/python by relative path; and a Claude
memory directory symlinked to the shared one, so a session started in the
seat keeps every note.
Landing work from a seat -- the rules in CLAUDE.md still hold: no
rebase, no cherry-pick, no force-push, never detached.
git push origin HEAD:master # fast-forward master on origin
# rejected as non-fast-forward? someone landed first:
git fetch origin && git merge origin/master # a merge commit is fine
git push origin HEAD:masterDrop-in work is unchanged: commit and push inside drop-ins/<name> first
(each seat's submodule checkout is a real clone with origin on GitHub),
then commit the pointer bump on the seat branch, then push as above.
The main checkout stays on master and only ever pulls
(git pull --ff-only). It is where the app runs: runtime/, logs/, the
mixer's stores and the 64 GB of session data live there and are not
duplicated into seats. Tests run fine inside a seat (the owner-state
guards in tests/conftest.py see the seat's own empty runtime/).
Current status:
pysdl2-dllalready ships SDL2 DLLs on Windows.- Audio auto-selection includes Windows loopback-style sources (WASAPI loopback / Stereo Mix / What-U-Hear style devices).
- CLI and pure-Python startup smoke paths are platform-neutral.
- Full rendering/audio runtime validation is still required on a physical Windows machine before GA.
- SDL driver: remove the
waylanddefault entirely; SDL auto-selects Cocoa. - Audio: use
sounddevicewith the BlackHole or Loopback virtual device for system audio capture. - OpenGL 3.3 core is supported on macOS 10.9+, but Apple deprecated OpenGL
in macOS 10.14. For long-term support, consider a Metal backend via
moderngl-windowwith thepyobjcbackend.
Drop-ins live under drop-ins/<name>/ and each one is tracked as its own
private GitHub repository via a git submodule.
Conventions for a drop-in:
- Keep canonical source in
drop-ins/<name>/. - Add it as a submodule in the main repository.
- Set
NAME,AUTHOR,TAGSon the class. - Ensure the main app can discover/load it through
unicornviz/dropins.py. - Any hotkeys the drop-in introduces must be added to its own
HELP_ENTRIES(discovered by core and merged intounicornviz/overlays.py'sCORE_HELP_SECTIONS) — that is the single source of truth.
| Directory | Class | Effect Name | Key |
|---|---|---|---|
alien-invasion-01 |
AlienInvasion |
Alien Invasion | — |
cyber-war-01 |
CyberWar |
Cyber War | — |
disco-ball-01 |
DiscoBall |
Disco Ball | — |
hacker-terminal-01 |
HackerTerminal |
Hacker Terminal | — |
textures-01 |
TextureShowcase |
Texture Showcase | — |
tron-grid-01 |
TronGrid |
Tron Grid | — |
unicorn-tears-01 |
UnicornTears |
Unicorn Tears | U |
webcam-01 |
WebcamSystem |
Webcam System | keypad |
images-01 |
ImageShowcase |
Image Showcase | — |
| Directory | Class | Purpose |
|---|---|---|
multi-head-01 |
MultiHeadController |
Multi-monitor display topology & mirroring |
control-room-01 |
ControlRoomController |
Secondary operator window with preview, transport, FX, and effect-browser controls |
Phase 2 includes evaluation scaffolds (not GA packaging):
- Flatpak manifest:
packaging/flatpak/io.unicornviz.UnicornViz.yml - Snap manifest:
packaging/snap/snapcraft.yaml
Recommendation for beta: keep shipping source + venv workflow, and use these manifests as iterative packaging baselines while compatibility matrix checks continue to mature.
- All effects smoke-tested headless:
python -c "from unicornviz.effects.registry import get_effects; get_effects()" -
config.tomldefaults matchunicornviz/config.py_DEFAULTS -
requirements.txtpins are current and minimal - ANSI files present in
assets/ansi/acid/(runtools/fetch_acid_ans.pyif missing) -
run.shis executable (chmod +x run.sh) - All drop-in submodules committed and pushed in their own repos before main-repo pointer update
- Every new hotkey is listed in the owning module's
HELP_ENTRIES(core sections live inunicornviz/overlays.py'sCORE_HELP_SECTIONS) - Screenshots taken at 1920×1080 for README