PartyKeysProtocol
GitHub
Open Source · MIT

Light up any key, from anywhere.

The open MIDI + SysEx spec for PartyKeys keyboards. Connect from a browser, a script, or hand it to your AI assistant.

Quickstart

Plug in a PartyKeys 36, open Chrome DevTools on any page, and paste:

JavaScript · Web MIDI
const midi = await navigator.requestMIDIAccess({ sysex: true });
const out = [...midi.outputs.values()].find(p => /partykey/i.test(p.name));

// Enter LED mode (required once)
out.send([0xF0, 0x05, 0x30, 0x7F, 0x7F, 0x20, 0x00, 0x0F, 0x01, 0xF7]);

// Light keys 17, 18, 19 red/green/blue
const enc = v => [Math.floor(v/128), v % 128];
out.send([
  0xF0, 0x05, 0x30, 0x7F, 0x7F, 0x20, 0x00, 0x15, 0x03,
  ...enc(255), ...enc(0),   ...enc(0),   1, 17,
  ...enc(0),   ...enc(255), ...enc(0),   1, 18,
  ...enc(0),   ...enc(0),   ...enc(255), 1, 19,
  0xF7
]);
That's it. Three things matter: (1) ask for SysEx access, (2) send the "enter LED mode" message once, (3) send CMD 15 per-key RGB messages whenever the lights should change. Everything else is convenience.

Protocol

PartyKeys 36 — 36 keys, MIDI 48–83, USB. Three commands are all you need.

PartyKeys 36

1. Connect

SysEx must be explicitly enabled — without it every LED command is silently dropped.

JavaScript
const access = await navigator.requestMIDIAccess({ sysex: true });
const out = [...access.outputs.values()].find(p => /partykey/i.test(p.name));

2. Enter LED mode

Send once after each (re)connect. Color commands sent before this are no-ops.

SysEx
F0 05 30 7F 7F 20 00 0F 01 F7

3. CMD 15 — per-key RGB

Set any number of keys to any 24-bit RGB color in one message. Group keys that share a color.

SysEx frame
F0 05 30 7F 7F 20 00 15 <numGroups>
  <group_0> <group_1> ...
F7

# Each group:
#   R_hi R_lo  G_hi G_lo  B_hi B_lo  keyCount  key0 key1 ...
Field Bytes Range Description
R_hi R_lo20x00–0x01, 0x00–0x7FRed channel (14-bit MIDI-safe)
G_hi G_lo2sameGreen channel
B_hi B_lo2sameBlue channel
keyCount11–36Number of key indices that follow
key0 … keyNN0–35Key indices (0 = leftmost C3, 35 = rightmost B5)

Color encoding — MIDI is 7-bit, so each 0–255 channel splits into a pair:

Encoder
// v ∈ [0, 255]   →   [high, low]
const enc = v => [Math.floor(v / 128), v % 128];
// firmware reconstructs: value = high × 128 + low
Color value high low Hex bytes
0 (off)0x000x0000 00
510x000x3300 33
1280x010x0001 00
255 (max)0x010x7F01 7F

4. All off

SysEx
F0 05 30 7F 7F 20 00 71 00 F7

5. Key layout

Key index MIDI note Note name Position
048C3Leftmost
1765F4Middle
3583B5Rightmost

General formula: midi_note = key_index + 48. 36 keys total, 3 octaves C3–B5.

Note-on echo. The legacy way to light a key (0x90 note 0x40) makes the firmware echo a note-on back as if the player pressed it. Use CMD 15 — it doesn't echo.
LED latency ≈ 200 ms. Don't pre-fire the SysEx. Send it at the beat and delay your audio + visuals by the same constant.

PopuPiano 29 (alt device)

29 keys, MIDI 48–76, USB or BLE. Two-step: upload palette, assign slots. No "enter mode" needed.

1. Upload a palette (CMD 0x1E)

Uploads N colors into the device palette starting at slot 1. Colors are 7-bit RGB — scale 8-bit by min(127, round(v / 2)).

SysEx · CMD 0x1E
F0 03 1E <numColors> 01  <R G B> × N  F7

# Example: upload red, green, blue into slots 1–3
F0 03 1E 03 01  7F 00 00  00 7F 00  00 00 7F  F7

2. Assign palette slots to keys (CMD 0x20)

Assigns a palette slot to each of the 29 keys. lampID is 0–28; paletteSlot 0 turns a key off.

SysEx · CMD 0x20
F0 03 20 <numPairs>  <lampID paletteSlot> × N  F7
Tip. Slot 0 is reserved as "off" and cannot be overwritten. The palette persists on the device until power-cycle, so live color updates only need to re-send CMD 0x20 — at audio rate if needed.

SDK Examples

Same task in four languages: connect, init, light keys 17–19 red/green/blue.

Browser · Web MIDI
const MFR = [0xF0, 0x05, 0x30, 0x7F, 0x7F, 0x20, 0x00];
const enc = v => [Math.floor(v / 128), v % 128];

const access = await navigator.requestMIDIAccess({ sysex: true });
const out = [...access.outputs.values()].find(p => /partykey/i.test(p.name));
out.send([...MFR, 0x0F, 0x01, 0xF7]);   // enter LED mode

function setLEDs(groups) {
  const msg = [...MFR, 0x15, groups.length];
  for (const g of groups) {
    msg.push(...enc(g.rgb[0]), ...enc(g.rgb[1]), ...enc(g.rgb[2]));
    msg.push(g.keys.length, ...g.keys);
  }
  msg.push(0xF7);
  out.send(msg);
}

setLEDs([
  { rgb: [255, 0,   0  ], keys: [17] },
  { rgb: [0,   255, 0  ], keys: [18] },
  { rgb: [0,   0,   255], keys: [19] },
]);

pip install mido python-rtmidi

Python · mido
import mido

MFR = [0x05, 0x30, 0x7F, 0x7F, 0x20, 0x00]
enc = lambda v: [v // 128, v % 128]

out = mido.open_output(next(n for n in mido.get_output_names()
                            if 'partykey' in n.lower()))
out.send(mido.Message('sysex', data=MFR + [0x0F, 0x01]))   # enter LED mode

def set_leds(groups):
    payload = MFR + [0x15, len(groups)]
    for rgb, keys in groups:
        payload += enc(rgb[0]) + enc(rgb[1]) + enc(rgb[2])
        payload += [len(keys)] + list(keys)
    out.send(mido.Message('sysex', data=payload))

set_leds([
    ((255, 0,   0  ), [17]),
    ((0,   255, 0  ), [18]),
    ((0,   0,   255), [19]),
])

npm install jzz

Node.js · JZZ
import JZZ from 'jzz';

const MFR = [0xF0, 0x05, 0x30, 0x7F, 0x7F, 0x20, 0x00];
const enc = v => [Math.floor(v / 128), v % 128];

const engine = await JZZ();
const name = engine.info().outputs.find(p => /partykey/i.test(p.name)).name;
const out = JZZ().openMidiOut(name);

out.send([...MFR, 0x0F, 0x01, 0xF7]);   // enter LED mode

function setLEDs(groups) {
  const msg = [...MFR, 0x15, groups.length];
  for (const g of groups) {
    msg.push(...enc(g.rgb[0]), ...enc(g.rgb[1]), ...enc(g.rgb[2]));
    msg.push(g.keys.length, ...g.keys);
  }
  msg.push(0xF7);
  out.send(msg);
}

setLEDs([
  { rgb: [255, 0,   0  ], keys: [17] },
  { rgb: [0,   255, 0  ], keys: [18] },
  { rgb: [0,   0,   255], keys: [19] },
]);

Link CoreMIDI.framework — no third-party deps.

Swift · CoreMIDI
import CoreMIDI

let MFR: [UInt8] = [0xF0, 0x05, 0x30, 0x7F, 0x7F, 0x20, 0x00]
func enc(_ v: UInt8) -> [UInt8] { [v / 128, v % 128] }

var client = MIDIClientRef(); var port = MIDIPortRef()
MIDIClientCreate("PK" as CFString, nil, nil, &client)
MIDIOutputPortCreate(client, "out" as CFString, &port)

var dest: MIDIEndpointRef = 0
for i in 0..<MIDIGetNumberOfDestinations() {
    let d = MIDIGetDestination(i)
    var n: Unmanaged<CFString>?
    MIDIObjectGetStringProperty(d, kMIDIPropertyName, &n)
    if ((n?.takeRetainedValue() as String?) ?? "").lowercased().contains("partykey") {
        dest = d; break
    }
}

func sendSysEx(_ data: [UInt8]) {
    var pkt = MIDIPacketList()
    let p = MIDIPacketListInit(&pkt)
    _ = MIDIPacketListAdd(&pkt, 1024, p, 0, data.count, data)
    MIDISend(port, dest, &pkt)
}

sendSysEx(MFR + [0x0F, 0x01, 0xF7])      // enter LED mode

func setLEDs(_ groups: [(rgb: (UInt8, UInt8, UInt8), keys: [UInt8])]) {
    var msg = MFR + [0x15, UInt8(groups.count)]
    for g in groups {
        msg += enc(g.rgb.0) + enc(g.rgb.1) + enc(g.rgb.2)
        msg += [UInt8(g.keys.count)] + g.keys
    }
    msg.append(0xF7)
    sendSysEx(msg)
}

setLEDs([
    (rgb: (255, 0,   0  ), keys: [17]),
    (rgb: (0,   255, 0  ), keys: [18]),
    (rgb: (0,   0,   255), keys: [19]),
])

AI Assistant

Two ways to give your AI assistant full protocol knowledge.

One-click LLM Prompt

Paste into Claude, ChatGPT, Codex, Cursor — anywhere.

📋 Paste into any AI chat
You are a PartyKeys Protocol expert. PartyKeys is an open-source MIDI keyboard
with addressable RGB LEDs per key. Two devices exist:

═══ DEVICE A — PartyKeys 36 ═══
• 36 keys, MIDI 48–83 (C3–B5), key_index = midi_note − 48
• USB MIDI, requires SysEx: requestMIDIAccess({sysex:true})
• Manufacturer header: 05 30 7F 7F 20 00
• Detect by port name containing /partykey/i

PROTOCOL:
1. Enter LED mode (REQUIRED ONCE after connect):
   F0 05 30 7F 7F 20 00 0F 01 F7

2. CMD 0x15 — per-key RGB (the workhorse):
   F0 05 30 7F 7F 20 00 15 <numGroups>
     <group> × N
   F7

   Each group: R_hi R_lo  G_hi G_lo  B_hi B_lo  keyCount  key0 key1 ...

   Color encoding (MIDI is 7-bit, channel is 0–255):
     high = floor(v / 128)    low = v % 128
     255 = [0x01, 0x7F]   128 = [0x01, 0x00]   0 = [0x00, 0x00]

3. All off:
   F0 05 30 7F 7F 20 00 71 00 F7

4. CMD 15 does NOT echo. Legacy note-on LEDs (0x90 note 0x40) DO echo
   back to your input handler — always prefer CMD 15.

5. Hardware LED latency ~200 ms. To sync with audio: fire SysEx at the
   beat, delay audio + visuals by ~200 ms.

═══ DEVICE B — PopuPiano 29 ═══
• 29 keys, MIDI 48–76 (C3–E5), USB or BLE MIDI
• Manufacturer header: 03 — detect by port name /popupiano/i
• No "enter mode" message needed.

PROTOCOL:
1. CMD 0x1E — upload palette (7-bit RGB; scale 8→7 as v/2):
   F0 03 1E <numColors> 01  <R G B> × N  F7
   (Slot 0 reserved as "off"; custom colors live in slots 1–127.)

2. CMD 0x20 — assign palette slots to keys:
   F0 03 20 <numPairs>  <lampID paletteSlot> × N  F7
   (lampID 0–28, slot 0 = off; palette persists until power-cycle.)

RULES:
✅ Always {sysex:true} for PartyKeys 36
✅ Always send "enter LED mode" before any color on PartyKeys 36
✅ Use delta updates — only send keys that changed
❌ Don't send 8-bit color on the wire — must be 7-bit encoded

Write working integration code in the user's language (JS/Web MIDI,
Python/mido, Node/JZZ, Swift/CoreMIDI).

Spec: https://github.com/allen4z/PartykeysProtocol
Docs: https://www.partykeys.com/developers

CLAUDE.md / .cursorrules template

Drop into your repo root. Claude Code, Cursor, and Copilot read it automatically.

CLAUDE.md · .cursorrules · .github/copilot-instructions.md
# PartyKeys Project Conventions

This project integrates with **PartyKeys Protocol** — open MIDI + SysEx
spec for the PartyKeys 36 (C3–B5, MIDI 48–83) keyboard.

## Protocol cheat sheet

Always request SysEx:
```js
const access = await navigator.requestMIDIAccess({ sysex: true });
```

Always send "enter LED mode" once after (re)connect:
```
F0 05 30 7F 7F 20 00 0F 01 F7
```

Per-key RGB is the primary command (CMD 0x15). Each 8-bit color channel
splits 7-bit: `high = floor(v/128); low = v % 128`.

Frame:
```
F0 05 30 7F 7F 20 00 15 <numGroups>
  <R_hi R_lo G_hi G_lo B_hi B_lo keyCount key0 key1 ...> × N
F7
```

All off: `F0 05 30 7F 7F 20 00 71 00 F7`

## Conventions

- Use the helper `setLEDs(groups)` — never craft raw SysEx at call sites.
- Use delta updates: only send keys whose color is changing.
- Prefer CMD 15 over legacy note-on LEDs (those echo back as fake input).
- Hardware LED latency ≈ 200 ms; if syncing to audio, delay audio + visuals
  by `HW_LATENCY_MS` instead of pre-firing SysEx.
- Always `allOff()` on app teardown.

Spec: https://github.com/allen4z/PartykeysProtocol

FAQ

My color commands do nothing.
Three checks: (1) {sysex: true} in requestMIDIAccess, (2) sent "enter LED mode" after connect, (3) you encoded each 8-bit channel as two 7-bit bytes. That's 99% of cases.
LEDs trail my audio.
Hardware delay is ~150–250 ms. Don't pre-fire SysEx — send at the beat, then delay your audio + visuals by ~200 ms (one constant).
My input handler fires when I light a key.
You're using legacy note-on LEDs (0x90 note 0x40). The firmware echoes those as fake player input. Switch to CMD 15 — it doesn't echo.
Which browsers / OSes work?
PartyKeys 36 needs Chrome or Edge (Web MIDI + SysEx). For Safari/iOS use the Swift example. The device is class-compliant USB MIDI — no driver install.