Internals

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 RGB

The 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:

ArrayMeaning
Nnumber of LEDs (all shapes, in shape order)
LX, LYposition on the 16 x 9 wall view
PX, PYroom position: PX 0 to 1.78 left to right, PY 0 at the floor to 1 at the ceiling
LU, LHLX / 16 and 1 - LY / 9
LRdistance from the room's centre, about 0-1
LSposition along its own shape, 0-1 (loops: 0 to just under 1)
LAangle around the shape's centre, 0-1 (loops and matrices; LS for open shapes)
LDdistance from the shape's centre, 0-1
LGposition along the whole wiring chain, 0-1
LFindex of the shape the LED belongs to
LL1 for loop shapes (circle, rect, hex, tri)
shapes, chainStartthe 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).

FieldTypeMeaning
tnumbermusic time in seconds (multiplied by the effect's speed setting)
gobjectbeat 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
fixedbooleanthe user picked a palette (or a colour block is active): effects with colours of their own should use pal instead
punchnumberhow hard hits land for the style: party 1, poprock 0.85, hiphop 1, chill 0.35, emotional 0.1
loudnumberloudness, 0-1
pulseT, pulses, pulseNnumber, number[], numberthe last kick (or the grid beat when no kick for 1.6 s), the kicks of the last second, a running kick count
pulsenumberexp(-(t - pulseT) / 0.2) * punch: the main beat envelope
kickE, snareE, hatEnumberenvelopes 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's COL (render() zeroes it first and points it at the caller's buffer). Use add(i, [r, g, b], k); values are linear, about 0-2 (more saturates softly in toRGB8).
  • Time comes from S.t and S.g only, never from Date or performance.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 gives o.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 in PX units), the bounding box BB and CORNERS.
  • tag: calm, build, drop, home (everyday looks), LedFx-like or any; 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

  1. Add the object to FX, FX2, FX3 or FX4 in packages/engine/src/effects.js (ids are unique; the list order is the UI order).
  2. To have automatic mode use it, add its id to POOLS (calm, build, drop, used by party) and/or to the low/mid/peak pools of the other styles in STYLES.
  3. Run npm run docs to regenerate docs/effects.md.
  4. 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:

StyleModePoolsCrossfadeeveryBrightness floorpumpmomentdyn
party (Party / EDM)sections: by song partcalm, build, drop0.3 s10.06111
poprock (Pop / Rock)levels: by loudnesslow, mid, peak0.4 s10.070.80.850.85
hiphop (Hip-hop / Trap)levelslow, mid, peak0.3 s10.07111
chill (Chill / Lo-fi)levelslow, mid, peak1.2 s20.10.30.40.5
emotional (Emotional / Ballad)levelslow, mid, peak2.0 s20.1200.250.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:

  1. Manual mode: the chosen effect (show.effect).

  2. A show timeline for this song: the block at t (an effect banned since is skipped).

  3. Otherwise chooseStep() in show.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 levels styles 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 from show.everyBeats (default 8) x every: 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, for varyBars() bars), not a new effect. settings.variations: false turns 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.

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 levels styles 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's dyn sets how far: 0 leaves effects as they are.
  • Kick pump (drops, any mode; levels styles 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 is 0.8 * settings.punch (default 0.7) times the style's pump. 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 keyframes

cleanTimeline() 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.

Edit on GitHub