๐ AirStems won the LALAL.AI Special Prize at the Musixmatch Musicathon 2026.
A build log on turning LALAL.AI's separated stems into a real-time, gesture-controlled instrument.
What AirStems is
AirStems lets you conduct and remix a real song with your bare hands in front of a webcam. Raise or fold a finger and a stem โ vocals, drums, bass, other โ drops in or out. Make a fist and the whole track cuts; open your hand and it all comes back. Your left hand's height opens and closes a low-pass filter, and spreading your fingers adds reverb. Everything is locked to the beat, so a drop always lands musically, and synced lyrics scroll underneath, karaoke-style.
It started at the Musixmatch Musicathon and is built on top of my open-source gesture instrument, Aetheric Geometry. The idea was simple: I already had an engine that let my hands play synthesised oscillators, so what if the thing my hands played was the actual vocals, drums and bass of a real track? LALAL.AI is what turns a finished song back into those independent parts, so it became the source the whole instrument plays.
LALAL.AI published an interview about the story and the idea; this post is the technical companion โ how the stems come in, how they become a real-time instrument, and the beat-sync engine that ties it together.
System overview
There are two halves, and keeping them apart is the single most important design decision in the project.
Offline preparation (runs once per song, and is allowed to touch the network):
- LALAL.AI API โ separates the track into stems (WAV).
- Musixmatch โ time-synced lyrics (line- and word-level).
- Cyanite โ BPM / key / mood tags.
Each writes its output to disk, indexed by the song's name:
stems/<song>/vocals.wav drums.wav bass.wav other.wav
lyrics/<song>.lrc (or .richsync.json)
analysis/<song>.json
Real-time app (runs every frame, and never touches the network):
webcam โโบ MediaPipe Hands โโบ gesture state โโบ StemEngine (sounddevice callback) โโบ speakers
โฒ
stems + beat grid, loaded into memory on song load
The live loop only ever reads local files that are already in memory. No part of a network request is on the audio path, and that separation is what keeps the instrument glitch-free while you play.
Deep dive: stem separation โ a real-time instrument
This is the core of the project, so I'll spend the most time here.
Getting the stems. The LALAL.AI flow is three steps: upload the file, request a split, poll until it is done, then download the WAVs.
sid = upload(path) # POST /upload/ -> source id
for stem in ("vocals", "drum", "bass", "piano"):
tid = split(sid, stem=stem) # POST /split/stem_separator/
node = wait(tid) # poll POST /check/ until success
download_tracks(_tracks(node), out) # save <stem>.wav
I request each stem I want and save the result under stems/<song>/. The app is deliberately source-agnostic: it loads whatever WAVs are in that folder, so during testing I can drop in local Demucs output or files from the LALAL.AI website without changing the engine at all.
Turning files into something playable. When a song loads, every stem is read, forced to stereo, resampled to the engine's rate, and โ importantly โ padded so that all stems share the exact same length. That makes them sample-aligned from time zero, which is what lets me mix them sample-for-sample later.
data, sr = sf.read(path, dtype="float32", always_2d=True)
if data.shape[1] == 1: data = np.repeat(data, 2, axis=1) # mono -> stereo
if sr != SAMPLE_RATE: data = resample(data, sr, SAMPLE_RATE)
...
length = max(len(a) for a in stems.values())
stems = {k: pad_to(a, length) for k, a in stems.items()} # align every stem
Everything is loaded fully into memory up front, so the audio callback never has to read from disk.
Which stems, and why. For this instrument, four parts is the sweet spot: vocals, drums, bass, and a fourth ("other", or piano/guitar). Four maps cleanly onto four fingers, and each part is musically meaningful on its own โ muting the drums or soloing the vocal are both instantly recognisable moves.
What mattered about the stems. For a live instrument, separation cleanliness matters far more than it does in a studio mix. In a mix, a little bleed between stems is masked because you hear everything together; here, the moment you solo the vocal or fully mute the drums, any bleed is exposed. Full-bandwidth, consistent-loudness stems delivered as lossless WAV are what make muting and soloing sound convincing in real time. As a useful side effect, the separated stems sum back to roughly the original track, so I can mix at close to unity gain without dividing for headroom.
Tradeoffs and honest feedback. Separation is not instant, which is exactly why it belongs in the offline stage โ you would never want to run it inside a live session. Two things would have streamlined my integration: a single call that returns all stems as one task, rather than issuing and polling a separate split per stem; and a firmer response schema, since I ended up writing defensive parsing to locate the download URLs across a couple of possible shapes. Neither is a dealbreaker. The separation quality is the part that actually makes the project possible, and that was consistently strong.
The beat-sync engine
This is the most original piece, so it gets real space. The problem it solves: if a hand gesture takes effect the instant you make it, drops and returns land at arbitrary points and it sounds sloppy. The fix is to quantise every change to the song's own beat.
On load, I run librosa's beat tracker on the drums stem (the cleanest source of a pulse) to get a grid of beat timestamps and the BPM:
tempo, beats = librosa.beat.beat_track(y=drums_mono, sr=SR, units="time")
self.beat_times = beats # array of beat timestamps, in seconds
Then, in the audio callback, a gesture does not change the mix directly. The hands write pending gains, and those are only committed to target gains when a beat boundary falls inside the current audio block:
# does a beat land inside this block?
t0, t1 = pos / SR, (pos + frames) / SR
on_beat = np.any((self.beat_times >= t0) & (self.beat_times < t1))
# commit the hand-wanted gains on the beat (or immediately if quantize is off)
if on_beat or not quantize:
self.target_gains = pending
So the hand decides what changes, and the beat grid decides when. A key press turns quantisation off for instant, raw control. The same beat crossing also drives a pulse value that the on-screen display flashes with โ a small touch that makes the whole interface feel locked to the music.
Hand tracking โ mix control
Hand tracking is MediaPipe Hands. I assign one hand as left and one as right, and map them differently.
Right hand = stems. Each finger toggles a stem by comparing the fingertip's Y position to the knuckle below it. The difficulty is jitter: right at the threshold, a finger flickers on and off. So instead of a single line, I use two margins โ the finger has to clearly cross to flip, and otherwise it holds its state:
if tip_y < pip_y - UP_MARGIN: finger_up = True # clearly up
elif tip_y > pip_y + DOWN_MARGIN: finger_up = False # clearly down
# else: hold the previous state (hysteresis removes the flicker)
Left hand = effects. Wrist height maps to the low-pass filter (lower hand, darker sound), and how open the hand is maps to reverb. Openness is measured in a scale-invariant way, as the mean fingertip-to-knuckle distance divided by palm length, so it behaves the same regardless of how far the hand is from the camera:
openness = mean(dist(tip, knuckle) for each finger) / palm_length
# roughly 0.3 for a fist, 0.9 for an open hand
Both continuous controls are smoothed with a simple exponential moving average, so they feel responsive without following the natural tremor of the hand.
Gesture map
| Hand | Gesture | Controls |
|---|---|---|
| Right | fingers up / down | stem 1โ4 in / out |
| Right | fist / open palm | full drop / full mix |
| Left | wrist height | low-pass filter (down = dark, up = open) |
| Left | open / close hand | reverb (open = full, fist = dry) |
| Keys | space ยท b ยท n | play/pause ยท beat-sync on/off ยท next song |
Audio engine internals
The engine is a single sounddevice output stream with a stereo, block-based callback. This is where the audio-focused details live.
Click-free mutes. Toggling a stem instantly would click, because the gain jumps. Instead, every block ramps each stem's gain from its current value to its target across the length of the block:
for name, audio in stems.items():
ramp = np.linspace(curr[name], target[name], frames) # per-block gain ramp
mix += audio[idx] * ramp[:, None]
curr[name] = target[name]
The effects chain is reused directly from Aetheric Geometry: a first-order IIR low-pass driven by hand height, a Schroeder reverb (four comb filters) driven by hand spread, and a light tremolo. The reverb's wet level is smoothed inside the callback, so opening your hand fades the space in rather than snapping it on:
cutoff = 200.0 * (8000.0 / 200.0) ** filter_bright # hand height -> cutoff in Hz
reverb_smooth += 0.08 * (reverb_wet - reverb_smooth) # ease the wet level in/out
Parameters cross from the main thread to the audio thread under a short lock, copied once at the top of each callback, so a gesture update never tears a block mid-render.
What was hard
- Making it feel musical rather than glitchy. The beat-sync quantiser was the single biggest change: before it, hand-driven changes sounded messy; after it, everything lands in time.
- Removing gesture flicker. Raw fingertip thresholds jitter, and the two-margin hysteresis above was what made stem toggles trustworthy.
- Real-time safety. The callback copies its parameters under a lock and avoids heavy allocation. The per-sample IIR and comb-reverb loops are the CPU hot path, so block size becomes the main knob for trading latency against stability on a given machine.
- Keeping the network off the audio path. Doing all separation, lyrics and analysis ahead of time, and loading the stems fully into memory, is what keeps playback from ever stalling.
Links & credit
- Live page: https://lluisestape-upc.github.io/AirStems/
- Demo video: https://youtu.be/BnNcGabujgc
- Source: https://github.com/lluisestape-upc/AirStems
- The story (interview): https://www.lalal.ai/blog/how-airstems-uses-lalal-ai-api/
- Built on my open-source gesture instrument Aetheric Geometry.
- Stems by LALAL.AI, lyrics by Musixmatch, analysis by Cyanite.
Questions, or a gesture you think I should map next? I'd love to hear it in the comments.
Top comments (0)