Edit below
/.*$#-\ \{+}1#/
Run online

Aaron Janse

I'm a recent Cal graduate interested in high‑performance systems and finance.

Syncing lights with music for college parties

Intro

Early on, I decided that I want the lights to plan the entire song in advance. I want the lights to know when the drop is and to save special effects for it. Our parties usually have audio playing from Spotify, Rekordbox (popular DJing software), or DJ Pro AI (automatic DJing software).

Read DJ software state via OCR, DB cracking, and fuzzy search

The first step to syncing lights to music playing from one of these platforms is figuring out what song is playing and how it’s being played. DJs will speed up / slow down a song to make transitions smoother, and they’ll jump between sections of the song. I call this data the “playback state”.

Extracting the playback state varies by platform. For Spotify, I could use AppleScript to read this data. For DJ Pro AI, I could use macOS accessibility libraries to parse the app’s UI. And for Rekordbox, I chose to regularly take screenshots and use OCR, because the accessibility libraries didn’t work. In several of these approaches, we might get a rounded seek time or, for OCR’d rekordbox, entirely erroneous numbers at times.

We’ll need to repeatedly “measure” the DJ software to estimate its actual, unrounded internal state. The current seek time (e.g. 1:43 into a song) is a moving target, so we instead collect estimates of the song start time; we can compute this as a function of the measurement time, measured seek time, and measured playback speed. For Rekordbox, we’ll need to be able to distinguish between OCR errors versus the DJ actually skipping to a different part of the song.

We also need to know the song structure. This could have been an entire project of its own, but I decided to use existing solutions. When using Rekordbox (bigger events), I’d parse Rekordbox’s local track analysis DB, which requires some decryption reverse engineering. I’d fuzzy search our OCR’d song title+author to find a matching track in the DB. The analysis contains timestamps for beats and measures in the song, along with breaking the track into sections like chorus, build up, bridge, etc. For some songs with a big drop, it contains timestamps of each moment the build-up intensifies; this data was used in the live demo video I included above.

For songs played via Spotify or DJ Pro AI, I used Spotify’s track analysis API after doing a fuzzy song search. The quality of Spotify’s analysis is far worse than Rekordbox, often misclassifying sections or even measures. But it was still substantially better than nothing, and it was nice to just hit play on Spotify and have my lights automatically go along.

You can see a visualization of all of this below:

Sidecar process · burst-then-bisect

Estimating Rekordbox's playhead

Rekordbox doesn't expose its current playback time over any API we can hit. We screenshot its window and OCR the time readout — but the readout only shows whole seconds, so a single read just tells us the song started inside a 1 second window. We intersect the windows from many reads to narrow down the actual start time. A 2 s burst of rapid screenshots gets us a tight interval; from there we bisect by timing each next screenshot to land exactly at the midpoint of what's left.

rekordbox · Deck A
0:48
Sunset Drive
PHOSPHENE · 124 BPM
📷 → tesseract → ""
read t
───►
(w−t−1, w−t]
wallocr readinterval for s
waiting for first screenshot…
virtual playback · what we believe rekordbox is showing right now
playhead = wall clock − estimated start time · ± half the interval width
Sunset Drive
PHOSPHENE · 124 BPM
—:——.——
0:00
4:12
How the intersection narrows the start time

Each OCR read gives a 1 second window the start time must fall in (each grey bar below is one read). We line them up against a shared number line and look for the longest stretch they all agree on — that pink band is our running estimate. Reads that fall outside the consensus (dashed) are dropped as OCR errors or stale screenshots. The narrower the pink band, the better we know the start time.

Number linewaiting…

Defining a library of light effects

I chose to define light effects as a stateful-looking python function over song measures, which emits snapshots of the desired state at each timestamp. I think light patterns “feel” imperative, like turning on and off a light to blink…

For example:

def two_color_blink_effect(lights, measures):
  color1, color2 = random_color(), random_color()
  for measure in measures:
    for i, beat in enumerate(measure):
      match i % 4:
        case 0:
          lights.color(color1)
        case 1:
          lights.color(None)
        case 2:
          lights.color(color2)
        case 3:
          lights.color(None)
      lights.at(beat)

Calling something like lights.dump() after calling the above function would return:

song_seek_positioncolor
1.123red
1.223none
1.323blue
1.423none

Markov chain for stateful strobes

So when do we want strobes? Ideally we have strobes for, say, 33% of the night. Strobes are the strongest tool we have to emphasize things, so it’s nice when they highlight a drop or are predictable to emphasize a rhythm. I wanted to build waves of energy in the night. So rather than uniformly strobing on 33% of drops, I used a markov chain to toggle whether strobes stay enabled/disabled when we switch between songs. This made it so that when you saw a strobe, you can probably expect a strobe to emphasize the next drop, without having strobes on for the entire night:

strobe scheduling · two-state markov

Strobes-on as a sticky coin flip

Each song boundary, we decide whether strobes are enabled. The top row uses a 2-state markov chain so strobes are "sticky" — you get stretches of strobe-on songs followed by stretches of strobe-off. The bottom row is an i.i.d. coin flip at the same long-run rate, for comparison: same density of strobes, but with no clumping.

P(on → on)0.85
stay strobing
P(off → off)0.93
stay dark
songs visible80
window width
π(on) — long-run share
31.8%
(1−poff) ÷ ((1−poff) + (1−pon))
MARKOV
stateful · clumps
36%
I.I.D.
π = 32%
41%
← earlier songsnow →
visible window: 80 songsstrobes-on in window →

Time-of-day effects

The beginning of a party (the pre-game) involves a lot of mostly-sober socializing. It’s nice to have good light to see each others’ faces, and the lighting should encourage people to talk with each other. As the night progresses, people get more into the music and dancing, and by the end of the night we want to go all out with strobes / blacklight / etc, because why not?

Putting it all together to make a light plan

I combine the things above fairly trivially to make a song. For an entire song, I’ll choose the available effects based on time of day and strobiness. I’ll use one effect per section of the song, and I had some special effects for songs with a build-up to a drop. I also added random strobes between song sections:

Pipeline inputs
user controls
Time of day
full energy · strobes ok · no UV
Strobes
strobe-tagged effects in rotation + downbeat pops on drops
filter effect library by inputs
Effect pool
13 / 15 eligible
Any time of day
00000000
00000000000000
00000000
Pregame & mid-party
Mid-party & late-night
00000000
Late-night only
0000000000000000
00000000000000
EFFECTS.filter(e => eligible(e, time-of-day, strobes))
pick one effect per song section (biased by energy)
Lights plan + song analysis
one effect per section · sections colored by type
INTROAmbient Spin
00000000
VERSEHalf Ring
BUILDDownbeat Flash
DROPBlackout Hit
BRIDGEAmbient Spin
BUILD8th Gate
DROPDownbeat Flash
OUTROWarm Fade
plan = concat(effect_for(section) for section in sections)8 segments · 8 sections

An interesting shortcut I took was entirely ignoring song transitions. For context, a virtual DJ deck usually has two songs; one is the main song and another is a song we’re mixing in/out.

[image of rekordbox]

I decided to focus on making the lights match the master song. The transitions kinda just work without efforts from the lights? The DJ usually lines up and bpm-adjusts the songs, so as you transition from song A to song B, song A’s light patterns don’t look weird when played over song B.

Communicating with the lighting hardware

Stage lights, including cheap ones from Amazon, often use DMX as a protocol to control them. It’s a basic serial protocol [TODO: based on frames? idk], with some silly limitations like a max baud rate in case of mistakes [TODO: fact check this!]. The physical connector is the same as an audio cable but with the male/female plugs swapped to reduce accidents. Rather than buying a proper USB-to-DMX converter, I cheaped out and programmed an Arduino to emit DMX signals based on serial data.

[image of arduino]

On the computer side, I’d take the song plan (in song playback space) and project it onto wall clock time based on the playback state. For example, if the DJ is playing a song at 1.2x speed, we’ll want to play the light effects 1.2x faster. I also had a fallback emergency loop in case anything went wrong, to keep the party going.

[visualization D]

Party Lights Sync — Architecture

How a DJ-friendly lighting pipeline turns a song into a synchronized show. Each box below is a stage in the pipeline; everything is interactive — tweak the inputs and watch the plan and physical lights respond.

version   v0.4.2
runtime   rust + dmx512
fixtures   2× moving head, 1× strobe, 1× uv
Track playback state
live · what the player exposes
Sunset Drive
PHOSPHENE
0:48 / 4:12
Playback speed
Track
Pipeline inputs
user controls
Time of day
full energy · strobes ok · no UV
Strobes
strobe-tagged effects in rotation + downbeat pops on drops
track metadata → analysis
Song analysis
from rekordbox DB + spotify api · cached
BPM124
KeyA min
Sections8
Downbeats130
INTROe=0.25
VERSEe=0.45
BUILDe=0.75
DROPe=1.00
BRIDGEe=0.35
BUILDe=0.80
DROPe=1.00
OUTROe=0.30
detect_sections(audio) → labeled segments + per-section energyposition: 0:48 / 4:12
generate_plan(song, time-of-day, strobes)
Lights plan
static · regenerated when song or inputs change
INTROAmbient Spin
00000000
VERSEHalf Ring
BUILDDownbeat Flash
DROPBlackout Hit
BRIDGEAmbient Spin
BUILD8th Gate
DROPDownbeat Flash
OUTROWarm Fade
plan = concat(effect_for(section) for section in sections)8 segments
+ live playback state
Lights playbacksample(plan, t)
plan ⊗ playback state → live state
VERSE · measure 25
Half Ring
1
2
3
4
Beat
99.20
Phase
72°
Rot
113°
Dots
4/8
effect.sample(beat=99.20)
→ blackout: false · strobe: false · uv: false
0000000000000000
DMX OUT · 16 ch
● LIVE
DMX channel timeline24 beats · playhead = now
BEAT
COLOR ch 1–3
SPEED ch 4
STROBE ch 5
UV ch 6
25
26
27
28
29
30