Tesla's official way to make a custom light show is to install xLights, load their template project, sequence 48 channels by hand, and export. That's fine if you enjoy xLights. I wanted to generate shows from code instead, in the browser, so I read the format and wrote the file myself. It turned out to be about 60 lines.
What the car accepts
Tesla's light-show repo is strict about the file:
- FSEQ version 2.0, uncompressed. xLights can export compressed V2 too; the car won't play it.
- 48 channels per frame (lights, plus mirrors, windows, trunk, charge port and so on).
- A step time of 20 ms is what their template uses. Their validator allows 15 to 100 ms.
- The
.fseqsits in aLightShowfolder on a USB stick next to an audio file with the same name:party.fseq+party.wav.
So the whole show is a grid: frames × 48 bytes, one byte per channel per 20 ms. A 3-minute song is 9,000 frames, which is 432,000 bytes of light data. No compression needed.
Channel values
For lights, 0 is off and 255 is on. In between, a few specific values mean "ramp":
const pct = p => Math.floor(p * 255 / 100); const VALUE = { ON: 255, OFF: 0, ON_RAMP_500: pct(70), ON_RAMP_1000: pct(80), ON_RAMP_2000: pct(90), OFF_RAMP_500: pct(10), OFF_RAMP_1000: pct(20), OFF_RAMP_2000: pct(30), // closures (mirrors, charge port, trunk...) OPEN: pct(25), DANCE: pct(50), CLOSE: pct(75), }; Channel numbers come from the xLights template. In 0-based indexes, a few useful ones:
const CH = { LEFT_OUTER_MAIN: 0, RIGHT_OUTER_MAIN: 1, LEFT_SIGNATURE: 4, RIGHT_SIGNATURE: 5, LEFT_FRONT_TURN: 12, RIGHT_FRONT_TURN: 13, BRAKE: 24, LEFT_TAIL: 25, RIGHT_TAIL: 26, LICENSE_PLATE: 29, LEFT_MIRROR: 34, RIGHT_MIRROR: 35, CHARGE_PORT: 45, }; Filling the grid
const STEP_MS = 20, CHANNELS = 48; function createGrid(seconds) { const frames = Math.ceil(seconds * 1000 / STEP_MS); return { frames, data: new Uint8Array(frames * CHANNELS) }; } function set(grid, channel, t0, t1, value) { const f0 = Math.max(0, Math.round(t0 * 1000 / STEP_MS)); const f1 = Math.min(grid.frames, Math.round(t1 * 1000 / STEP_MS)); for (let f = f0; f < f1; f++) grid.data[f * CHANNELS + channel] = value; } // 10 seconds, low beams alternating on every beat at 120 BPM const grid = createGrid(10); for (let t = 0; t < 10; t += 0.5) { set(grid, CH.LEFT_OUTER_MAIN, t, t + 0.25, VALUE.ON); set(grid, CH.RIGHT_OUTER_MAIN, t + 0.25, t + 0.5, VALUE.ON); } The header
FSEQ v2 starts with a 32-byte fixed header, then optional "variable headers", then the channel data. Everything is little-endian.
| Offset | Size | Value |
|---|---|---|
| 0 | 4 | PSEQ |
| 4 | 2 | offset where channel data starts |
| 6 | 1 | minor version = 0 |
| 7 | 1 | major version = 2 |
| 8 | 2 | header length = 32 |
| 10 | 4 | channels per frame = 48 |
| 14 | 4 | number of frames |
| 18 | 1 | step time in ms = 20 |
| 19 | 1 | flags = 0 |
| 20 | 1 | compression type = 0 (none) |
| 21 | 1 | number of compression blocks = 0 |
| 22 | 1 | number of sparse ranges = 0 |
| 23 | 1 | reserved |
| 24 | 8 | unique id (any 64-bit number) |
The variable headers are tiny records: 2-byte length, 2-character code, then a null-terminated string. xLights writes mf (media file) and sp (the program that made it). I write the same two. The data offset has to be rounded up to a multiple of 4.
function toFseq(grid, mediaName) { const variable = (code, text) => { const bytes = [...text].map(c => c.charCodeAt(0) & 0x7f).concat(0); const len = 4 + bytes.length; return [len & 255, len >> 8, code.charCodeAt(0), code.charCodeAt(1), ...bytes]; }; const vars = [...variable("mf", mediaName), ...variable("sp", "my-generator")]; const dataOffset = Math.ceil((32 + vars.length) / 4) * 4; const buf = new Uint8Array(dataOffset + grid.frames * CHANNELS); const dv = new DataView(buf.buffer); buf.set([0x50, 0x53, 0x45, 0x51]); // "PSEQ" dv.setUint16(4, dataOffset, true); buf[6] = 0; buf[7] = 2; // v2.0 dv.setUint16(8, 32, true); dv.setUint32(10, CHANNELS, true); dv.setUint32(14, grid.frames, true); buf[18] = STEP_MS; // bytes 19-23 stay 0: no compression const id = Date.now() * 1000; dv.setUint32(24, id % 2 ** 32, true); dv.setUint32(28, Math.floor(id / 2 ** 32), true); buf.set(vars, 32); buf.set(grid.data, dataOffset); return buf; } In Node, fs.writeFileSync("LightShow/party.fseq", toFseq(grid, "party.wav")). In the browser, wrap it in a Blob and download it.
To check the output without a car, read the header back with Python's struct.unpack('<4sHBBHIIBBBBB', data[:23]) and confirm: PSEQ, version 2.0, 48 channels, compression 0, and file size = data offset + 48 × frames.
Things I learned the hard way
Short flashes look bad. At 20 ms per frame you can technically blink for one frame. On the car anything under roughly 100 ms reads as flicker, and Tesla's guide says as much. I hold every flash for at least 5 frames.
Closures have a budget. Mirrors, the charge port and the trunk have a limited number of moves per show, and they can stop on a hot day to protect the motors. If you fold the mirrors on every beat the car may refuse the show. I only move them at the two or three biggest moments in the song.
Filenames matter more than the header. The car finds the audio by the matching filename in the LightShow folder. Keep the mf header and the real file name the same anyway, so other tools don't get confused.
Audio length. Make the show the same length as the song. I compute frames from the decoded audio duration instead of hardcoding it.
Where this went
The writer above is the last step of a small browser tool I built: Tesla Light Show Maker decodes a song with the Web Audio API, finds the beats, places the lights on them, and hands you a zip with the LightShow folder ready for USB. The beat tracking is the hard part and a post of its own; the file format was the easy part once I had the table above.
If you've got a Tesla and want to try your own generator, the 60 lines in this post are all you need for a file the car will accept.

![Path Planning and Autonomous Navigation — Ch.11: Towing AMR Navigation [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvsxdf311sqylulf87m4.png)