Director and effects
The director (director.js) decides what the room shows each frame; the
effects (effects.js) draw it. Both are pure: they read a layout, a view of the
music and settings, and write linear RGB into a Float32Array. The runtime makes them in rebuild() and
rebuildEffects() and pushes settings into director.settings in syncDirector().
const L = buildLayout(shapes); // per-LED coordinates
const fx = createEffects(L, { custom: customEffects }); // {render(id, S, out), list, pools, styles, palettes}
const D = createDirector(L, fx); // {frame(V, t, extra, wall), settings, state, flash, identify, next}
const col = D.frame(V, t, extra, wall); // Float32Array(L.N * 3), linear RGBThe layout every effect reads
buildLayout(shapes) (layout.js) places every shape's LEDs on a 16 x 9 wall
view (x right, y down) and gives each LED coordinates. Effects only use these, which is why every effect works on any
layout:
| Array | Meaning |
|---|---|
N | number of LEDs (all shapes, in shape order) |
LX, LY | position on the 16 x 9 wall view |
PX, PY | room position: PX 0 to 1.78 left to right, PY 0 at the floor to 1 at the ceiling |
LU, LH | LX / 16 and 1 - LY / 9 |
LR | distance from the room's centre, about 0-1 |
LS | position along its own shape, 0-1 (loops: 0 to just under 1) |
LA | angle around the shape's centre, 0-1 (loops and matrices; LS for open shapes) |
LD | distance from the shape's centre, 0-1 |
LG | position along the whole wiring chain, 0-1 |
LF | index of the shape the LED belongs to |
LL | 1 for loop shapes (circle, rect, hex, tri) |
shapes, chainStart | the shapes, and the first LED index of each |
Shape types: line, pole, free (polylines through pts), circle, rect, hex, tri (closed outlines from
cx, cy, size, w, h, rot), matrix (cols x rows, serpentine wiring) and points (one LED exactly at each point:
camera mapping, Nanoleaf panels, smart bulbs, DMX fixtures). start rotates where a loop begins, rev reverses it.
The signals object S
buildSignals(V, t, opts) (signals.js) makes S from a view; the director
adds text and screen. With nothing playing, idleSignals(t, pal, fixed) gives the same fields for a slow made-up
beat (1.2 s, punch 0.2, loudness 0.35, no hits).
| Field | Type | Meaning |
|---|---|---|
t | number | music time in seconds (multiplied by the effect's speed setting) |
g | object | beat grid: {has, P, phase, idx, beatInBar, env, barPhase, beatT} (see engine analysis) |
pal | [[r,g,b],[r,g,b]] | the two palette colours, 0-1 |
fixed | boolean | the user picked a palette (or a colour block is active): effects with colours of their own should use pal instead |
punch | number | how hard hits land for the style: party 1, poprock 0.85, hiphop 1, chill 0.35, emotional 0.1 |
loud | number | loudness, 0-1 |
pulseT, pulses, pulseN | number, number[], number | the last kick (or the grid beat when no kick for 1.6 s), the kicks of the last second, a running kick count |
pulse | number | exp(-(t - pulseT) / 0.2) * punch: the main beat envelope |
kickE, snareE, hatE | number | envelopes of the last kick (τ 0.25 s), snare (0.15 s), hi-hat (0.12 s), times punch |
The kick and snare decays follow the tempo: at 128 BPM and slower they are as above; faster songs get shorter ones
(down to 55%), so one kick doesn't smear into the next. When the view knows how hard a kick was played (V.kickS,
0.3 to 1, from the analysis' optional kickStrength; or a live V.kickStrength), pulse and kickE are scaled by
it. Without it every kick counts as 1.
| snare, snares, hat | number, number[], number | time of the last snare, snares of the last 0.7 s, last hi-hat (-9 = none) |
| bassL | number | bass level 0-1 (live); 0.9 in drops, 0.3 otherwise for songs |
| bands | Float32Array(24) | live spectrum, 40 Hz to 10 kHz (zeros for songs without a live stream) |
| wave | Float32Array(256) or null | the last live samples |
| vocal | number or null | live vocal estimate, null when there is none |
| buildP | number | build-up progress 0-1 in a build; otherwise a slow 16-bar ramp |
| dropIn, sinceDrop | number | seconds to the next known drop and since the last one; 99 = none |
| text | string | what Text Scroller shows (settings.text: song name, BPM, clock or custom) |
| screen | object or null | the shared screen picture {w, h, px} for Screen Sync |
The effect contract
An effect is an object in one of the FX arrays inside createEffects():
{ id: "halfpulse", name: "Half Pulse", tag: "drop", run(S) {
const [a, b] = S.pal;
for (let i = 0; i < N; i++) add(i, LS[i] < 0.5 ? a : b, S.pulse * (0.3 + 0.7 * LH[i]));
} }run(S)adds light into the closure'sCOL(render()zeroes it first and points it at the caller's buffer). Useadd(i, [r, g, b], k); values are linear, about 0-2 (more saturates softly intoRGB8).- Time comes from
S.tandS.gonly, never fromDateorperformance.now(): the clock can jump (seeks, a song being recognised), run faster (the effect's speed) or be any clock when idle. - State that must persist between frames goes in
st(S, id, init), which resets when the effect starts again or the clock jumps (more than 0.6 s forward or back), and giveso.dt(≤ 50 ms) for simulations. - Helpers in scope:
hex(),rainbow(x),G(x, w)(a Gaussian),wrapd,hash(n),splat(px, py, r, c, k)(a soft light at a room position),lerp3,RW(room width inPXunits), the bounding boxBBandCORNERS. tag:calm,build,drop,home(everyday looks),LedFx-likeorany; it labels the effect in the UI and in effects.md. It doesn't put the effect in a pool by itself (custom effects excepted).- It must stay finite on every LED for any layout (one LED, a matrix, thousands of LEDs) and fast: the engine tests render every effect on 1,000 LEDs in under 4 ms.
Writing a new built-in effect
- Add the object to
FX,FX2,FX3orFX4inpackages/engine/src/effects.js(ids are unique; the list order is the UI order). - To have automatic mode use it, add its id to
POOLS(calm,build,drop, used byparty) and/or to thelow/mid/peakpools of the other styles inSTYLES. - Run
npm run docsto regenerate docs/effects.md. - Run
node --test packages/engine/test/engine.test.js: every effect renders finite colours and performance budget pick up the new one automatically.
The per-effect settings (PATCH /api/effects/:id {params}) work on any effect without its help: the director
renders it with t * speed, then applies a hue rotation and saturation as one 3 x 3 matrix, an intensity gain, and
an optional left-right mirror (each LED takes its mirror image's colour).
Pools and styles
A style says how the director listens and which effects it picks:
| Style | Mode | Pools | Crossfade | every | Brightness floor | pump | moment | dyn |
|---|---|---|---|---|---|---|---|---|
party (Party / EDM) | sections: by song part | calm, build, drop | 0.3 s | 1 | 0.06 | 1 | 1 | 1 |
poprock (Pop / Rock) | levels: by loudness | low, mid, peak | 0.4 s | 1 | 0.07 | 0.8 | 0.85 | 0.85 |
hiphop (Hip-hop / Trap) | levels | low, mid, peak | 0.3 s | 1 | 0.07 | 1 | 1 | 1 |
chill (Chill / Lo-fi) | levels | low, mid, peak | 1.2 s | 2 | 0.1 | 0.3 | 0.4 | 0.5 |
emotional (Emotional / Ballad) | levels | low, mid, peak | 2.0 s | 2 | 0.12 | 0 | 0.25 | 0.4 |
pump, moment and dyn scale the feel layer for the style (see below).
The style is the view's (from the analysis or the live estimate) unless show.style fixes one. choose() each frame:
Manual mode: the chosen effect (
show.effect).A show timeline for this song: the block at
t(an effect banned since is skipped).Otherwise
chooseStep()inshow.js. It picks the pool for the current part (sections:V.state;levels: the loudness level). Banned effects (settings.effectPrefs[id] === -1) are left out, liked ones (1) appear twice; if everything is banned the whole pool is used. Then:- A drop changes effect at once, as a hard cut (no crossfade). It opens with a high-impact effect from the pool
(
fx.impact: effects that hit on every kick), not one that played a moment ago. - Every other change waits for the next downbeat. An effect always lasts at least a bar.
- In
levelsstyles a level counts once it held most of a bar. A new song part (from the analysis) counts at once on its first downbeat, so level changes land with the part. - Effects change on the phrase grid: every
swapBars()bars, counted from where the part started. The pace comes fromshow.everyBeats(default 8) xevery: 4 bars at the default. In drops and peaks the look changes twice as often, but every other change is a variation (the palette's two colours swap and the room is mirrored, forvaryBars()bars), not a new effect.settings.variations: falseturns the variations off. - A banned effect leaves at once. A silence doesn't change the effect unless the effect is wrong for calm music.
Each change moves to the next pair of the automatic palette.
- A drop changes effect at once, as a hard cut (no crossfade). It opens with a high-impact effect from the pool
(
After rendering comes the feel layer, then: in a silence the room dims (the planned pause right before a drop, or the
first 1.5 s of quiet) or fades to the ambient effect; otherwise keepAlive() mixes the ambient effect in when the room
falls under the style's floor (lowered as much as the feel layer dimmed the effect), so quiet music never goes dark.
Then the moments (below). Then the accent layer (show.layer: {effect, amount}) is added on top, and zones
apply: each zone (room.zones, shapes join with shape.zone) follows the show, shows its own effect or the ambient
effect, gets its own brightness, or is off.
The feel layer
The feel layer works on the whole frame, on top of any effect. The effects themselves don't change.
- Energy contour (automatic mode). Each song part has a brightness to aim for: calm 0.08, a build-up rising from
0.12 to 0.32, a drop 0.34 (mean brightness after the LED roll-off). In
levelsstyles calm parts aim at 0.085, 0.13 or 0.2 by level. The aim eases in over 0.6 s and out over 0.4 s, and jumps at a drop. - Evening out (automatic mode). Each effect has a usual brightness (
fx.lum, measured in the example room). The director learns what it really is in this room while it plays (6 s average), and brings it toward the part's aim. It also learns how much the LED roll-off keeps a bright effect from its aim. The gain stays between 0.1 and 3.settings.dynamics(0 to 1, default 0.8) times the style'sdynsets how far: 0 leaves effects as they are. - Kick pump (drops, any mode;
levelsstyles also at their peak level). The frame dips between kicks and comes back on each one:1 - depth * (1 - hit * exp(-age / τ)), τ about a quarter beat (80 to 160 ms). The depth is0.8 * settings.punch(default 0.7) times the style'spump. An effect that doesn't light up on the kick by itself gets a deeper pump, up to 0.85 (learnt per effect). Kicks on the beat grid pump on the grid's beat, so a kick detected a little late still lands on time. The floor and the middle of the room pump a little more than the ceiling and the edges. Night mode (settings.night) makes it 0.4 x as deep. Safe mode caps it at 0.1. - Moments (every style, as big as its
moment). The last beat before a known next drop (V.nextDrop) dims, by up to 75%, without needing a silence. The first 0.7 s of a drop flash white (settings.dropFlash; 0.6 x in safe mode). The drop itself is a hard cut.
Custom effects
Made on the Effects page without code, stored in customEffects (at most 100) and compiled by customEffect(d):
{ id: "u-1a2b3c4d", name: "My effect", tag: "calm"|"build"|"drop"|"home",
pattern: "wave"|"chase"|"pulse"|"sparkle"|"fill"|"breathe"|"gradient",
axis: "along"|"x"|"y"|"around"|"centre", // LS, PX/1.78, PY, LA, LD
drive: "beat"|"kick"|"bass"|"loud"|"time", // S.pulse, S.kickE, S.bassL, S.loud, constant 1
colors: "palette" | ["#rrggbb", "#rrggbb"],
speed: 0.1-8 (1), width: 0.01-1 (0.15), bg: 0-0.5 (0.05), cycles: 0.25-8 (1) }The server validates them (cleanCustom in core.js) and the engine clamps them again (saved files and backups can
hold anything). A custom id can't replace a built-in one. Custom effects join the automatic pools by tag: calm,
build and drop into the party pools and low, mid, peak into the other styles (home joins none). Deleting
one removes every reference to it (forgetEffect: the shown effect, layer, ambient, zones, scenes, likes, song shows).
Shows and timelines
A remembered song can carry a show: a timeline the director plays instead of choosing on the fly (with
settings.shows on and automatic mode).
planShow(pre, fx, {seed, prefs, everyBeats}) (show.js) makes one from the
analysis. It plays the song through chooseStep(), the same function the live director uses, on songView in
quarter-beat steps. So an automatic show is exactly what the live director would have played, only fixed: the same
song always gets the same show. Variations and the feel layer are not stored in the show: the director adds them
while it plays (a block's part of drop counts as hot). Blocks shorter than a second (or two beats) and equal neighbours are merged;
the first block starts at 0 and the last ends at the song's end. Shows are versioned (showV; the planner's
current version is SHOW_VERSION in show.js): an automatic show made by an older version is planned again when
it's next read, while a show edited by hand is kept.
timeline.js adds what the show editor makes, all in seconds on the song's
clock, kept to the millisecond:
show: [{t0, t1, part, effect}] // effect blocks, sorted, no overlaps
colors: [{t0, t1, palette: "<id>"} | {t0, t1, colors: ["#rrggbb", "#rrggbb"]}] // override the palette
cues: [{t, type: "flash"|"strobe"|"blackout"|"build"|"drop", color?: "#rrggbb" /* flash only */, beats?: 0.25-128}]
bright: [{t, v: 0..1}] // straight lines between keyframescleanTimeline() validates them for PUT /api/songs/:id/show (at most 500 of each; unknown effects or palettes,
overlaps and times past the end are 400 errors). Cues last beats (default strobe 4, blackout 2, build 16, drop 16;
a flash is a single hit). In the director: build and drop cues change V.state (and buildP, dropT); a colour
block becomes S.pal with fixed true; flash cues fire once each as the clock passes them (createCueFirer: within
250 ms, so a seek forward skips them, a seek back re-arms them); strobe flashes at sixteenth notes; blackout and the
brightness curve multiply the result. The editor's unsaved version plays for two minutes through
runtime.setDraft().
Palettes and mood colours
fx.palettes maps an id to two linear RGB colours: neon, sunset, ocean, fire, ice, forest, mono, mood,
and auto, a list of five pairs (pink/orange, cyan/blue, violet/red, green/blue, yellow/red) that the director steps
through with every effect change (state.palIdx).
With show.palette auto and settings.moodColours on (or palette mood, for every effect), the palette follows the
music's mood: moodPalette(valence, energy, variant) (tonal.js) blends four
corners by smoothstepped valence and energy (happy and energetic: warm oranges and pinks; calm and major: teal and
green; calm and minor: deep blues and violets; tense: reds and magentas), two variants per corner so effect changes
still bring new colours, rescaled so blends stay as bright as the corners. The director eases towards it with a 2.5 s
time constant and drops it 5 s after the mood is gone. Night mode with warm on forces sunset.
Overlays, pads and safe mode
director.state.overlay holds the live pads, applied after everything else (runtime.action() sets them):
hold (one colour everywhere), flash (decays over 0.6 s, white or a colour), strobe (12 Hz), boost (gain
0-2), blackout, freeze (repeat the last frame), and identify (one shape lit white with a chase for 4 s, the
rest dimmed). The post hook runs before them, so the sunrise alarm, the sleep fade and the integrations' layer sit
on top of the show but under the pads.
Safe mode (settings.safe, also forced on in night mode) adds the photosensitivity limiter (WCAG 2.3.1): the mean
brightness may jump (to more than 1.8 x the recent level + 0.12) at most 3 times a second; further jumps are scaled
down. Night mode also turns the drop flash off, and DMX strobes and lasers never run in either.
Tests
packages/engine/test/engine.test.js (every effect finite, the time budget, custom effects, Text Scroller, Screen Sync),
live.test.js (quiet music never goes dark, blackout/flash/limiter, banned effects, zones, settings),
director.test.js (the feel layer: pump, drop moments, phrase grid, level hold, variations, kick strength),
feel.test.js (the feel scoreboard, npm run eval:feel, must not get worse than its baseline),
timeline.test.js (validation, lookups, the cue firer, cues in the director, a clock that jumps back) and
tonal.test.js (mood colours). See testing.