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.

User-confirmed · 2026-10-10

Verified lighting rules

Reuse the PartyKeys ChordCut lighting implementation: Web MIDI → PartyStudio / SAM5704 speaker relay → keyboard. Keep device profiles separate.

Profile fruit-webmidi-palette71-v1 · The user confirmed the ChordCut hardware chain. 25 offline regression tests pass. This is not a claim that every phone, firmware or spliced keyboard has been tested.

Choose the device profile first

PartyKeys / PartyStudio / SAM5704

Verified Web MIDI profile. Palette 0x71, actual MIDI notes 48–83, mode 0F 05. Use the reusable controller below.

Direct RGB

For an explicitly verified direct-device RGB profile: 0x15 + 0F 01, key indices 0–35. Do not apply its mode or large packets to the speaker relay.

Direct-device reference →

PopuPiano 29

Separate manufacturer 03, commands 1E / 20 / 23, lamp IDs 0–28. Not covered by this relay hardware confirmation.

PopuPiano reference →

Initialization and small-packet rules

  1. After output opens, enter palette mode using the 7F device-type byte; key-color frames still use 05.
  2. Clear after 8 ms, settle for 120 ms, then query and restore the current target. These are controller timings, not a measured physical LED latency.
  3. At most 3 note/color pairs per frame (16-byte SysEx), paced by at least 8 ms. MidiBrowser's 256-byte limit does not permit large relay packets.
  4. Send raw Web MIDI SysEx, without BLE timestamps. Recompute deltas after each successful write so a new chord or pause cancels stale pending notes.
SysEx · palette71 · hex
# Mode → 8 ms → clear → 120 ms → query → paced target
F0 7F 30 7F 7F 20 00 0F 05 F7
F0 05 30 7F 7F 20 00 71 00 F7
F0 05 30 7F 7F 20 00 01 F7

# Optional C4 E4 G4 C5 diagnostic; two packets, at least 8 ms apart
F0 05 30 7F 7F 20 00 71 03 3C 08 40 08 43 08 F7
F0 05 30 7F 7F 20 00 71 01 48 08 F7

Routing, reconnect and input safety

  • Recognize and select the output; only accept topology reports from its unambiguously paired input. Learn channel/address from CMD 02; use 7F/7F broadcast on that bus until known, not a hard-coded 3F.
  • CMD 04/05 triggers a debounced requery; CMD 18 updates octave routing. Do not reset the player's octave or treat a silent query as a disconnect.
  • Pause/stop: all off. Reconnect: initialize, clear, then restore the CURRENT target only. Dispose timers on exit; never close the host App's shared MIDI/BLE connection.
  • Never use Note On for lighting. Preserve the existing input parser, velocity, channels, running status and CC64. Screen, scoring and lights share actual MIDI notes; do not silently transpose out-of-range notes.
  • Do not globally mute audio. If separately implementing speaker audio routing, mute only the web piano after reliable speaker-online confirmation; keep SFX and BGM. Keyboard CMD 05 is not speaker-online evidence.

Reuse in another project

The ZIP contains the complete Skill, three dependency-free ESM modules and tests. Keep the modules together; use your existing Web MIDI access and input callbacks. The App owns Bluetooth pairing.

Codex Skill
Use $partykeys-protocol to connect this project's key lighting
using the user-confirmed PartyKeys ChordCut profile.
Preserve existing MIDI input, audio and host-owned connections.
JavaScript · existing MIDI connection
import {
  createPartyKeysLighting, lightingSysexAvailable
} from './skills/partykeys-protocol/assets/web-midi/connect-lighting.mjs';

// access: your existing MIDIAccess / injected Web MIDI connection.
// sysexRequestSucceeded: true only if {sysex:true} actually succeeded.
const lights = createPartyKeysLighting({
  inputs: access.inputs,
  outputs: access.outputs,
  outputId: selectedOutputId, // omit only when there is one supported output
  sysexEnabled: lightingSysexAvailable(access, sysexRequestSucceeded),
  onStatus: (state, detail) => console.info(state, detail)
});

// Keep the original parser/scoring/audio path; forward bytes alongside it:
// lights.receive(rawBytes, pairedInputId);
lights.set(currentTargetMidiNotes); // same notes as the screen
// Pause / stop: lights.off();
// Exit / output change: lights.dispose();
// Reconnect: recreate, then restore current target only if active.
// "sent" means written to the MIDI API, not a physical LED acknowledgment.
An injected bridge may omit sysexEnabled. The helper permits a successful {sysex:true} request unless explicitly denied. Ordinary-MIDI fallback alone never grants lighting. If unavailable, keep playing/scoring and show “Lighting unavailable”.

Run node --test skills/partykeys-protocol/scripts/lighting.test.mjs skills/partykeys-protocol/scripts/routing.test.mjs, then the target project's checks. Finally verify actual lamps, rapid chord changes and reconnect on the intended hardware.

Quickstart

The following quickstart and SDK examples are for a verified direct RGB device only. For Web MIDI / speaker-relay integration, use the verified palette71 rules above. Do not mix initialization modes.

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.

The following quickstart and SDK examples are for a verified direct RGB device only. For Web MIDI / speaker-relay integration, use the verified palette71 rules above. Do not mix initialization modes.

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. CMD 0x14 — Highlight / Darklight

Higher-level lighting that references a pre-loaded UI palette (paletteUI_k[]) on the device. Two semantic states for guided-play scenarios (chord trainers, sight-reading, sequencer playback) — Highlight for the key the player should press now, Darklight as a dimmer preview of the upcoming key.

State Command Palette slot Brightness Typical use
Highlight (高亮)Send_LightUp (0x14)paletteUI_k[n]× 1.0The key the player should press now
Darklight (预亮)Send_LightUp (0x14)paletteUI_k[n+14]× 0.75Next upcoming key, dimmed as a preview

Palette layout (28 slots). The UI palette is structured as:

Slots Color group
0Black (off)
1 – 12Highlight colors — full brightness × 1.0
13Black (off)
14 – 25Darklight colors — dim × 0.75 (same hues as 1–12)
26, 27White

Wire format. Both states are emitted as CMD 0x71 palette assignment. To light an entire C3 octave (keys 48–59) at Highlight or Darklight:

Historical direct-device example only: 3F is an address, not a universal destination; the 12-pair packet and dim/white palette slots require target-firmware verification. Do not copy it into the speaker relay. Use discovered routes and 3-pair packets there.
SysEx · CMD 0x71 — one-octave Highlight + Darklight
# Highlight one C3 octave — palette slots 1–12 (full brightness × 1.0)
F0 05 30 7F 3F 20 00 71 0C
  30 01  31 02  32 03  33 04  34 05  35 06
  36 07  37 08  38 09  39 0A  3A 0B  3B 0C
F7

# Darklight the same keys — palette slots 14–25 (dim × 0.75, same hues)
F0 05 30 7F 3F 20 00 71 0C
  30 0E  31 0F  32 10  33 11  34 12  35 13
  36 14  37 15  38 16  39 17  3A 18  3B 19
F7

5. All off

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

6. 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.
Measure physical latency. The older direct-device estimate was 150–250 ms. It is not a measurement of the speaker relay. Do not force a 200 ms audio delay or confuse the controller's 120 ms initialization settle with LED latency.

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.

The following quickstart and SDK examples are for a verified direct RGB device only. For Web MIDI / speaker-relay integration, use the verified palette71 rules above. Do not mix initialization modes.
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 implementing PartyKeys / 音乐密码 lighting. Select an explicit device
profile before constructing any packets. Do not guess unknown MIDI outputs.

VERIFIED WEB MIDI PROFILE — fruit-webmidi-palette71-v1 (2026-10-10)
User-confirmed in PartyKeys ChordCut: phone USB to the PartyStudio APP port,
then wireless to keyboard. Recognized relay aliases: PartyStudio, SAM5704.
Ordinary MIDI input is allowed for playing/scoring; proprietary output is not.

Reuse the complete Skill and dependency-free modules:
https://protocol.partykeys.ai/downloads/partykeys-protocol.zip
https://protocol.partykeys.ai/skills/partykeys-protocol/SKILL.md
https://protocol.partykeys.ai/skills/partykeys-protocol/references/verified-web-midi.md

Wire sequence (hex):
1. Open selected output, mode: F0 7F 30 7F 7F 20 00 0F 05 F7
2. After 8 ms, all off: F0 05 30 7F 7F 20 00 71 00 F7
3. Settle 120 ms, query: F0 05 30 7F 7F 20 00 01 F7
4. Palette target:
   F0 05 30 <channel> <address> 20 00 71 <count> <note color pairs> F7
   Actual MIDI notes 48–83, color 08 (cyan), 00 off.
   Maximum THREE pairs / 16 bytes; pace packets by at least 8 ms.
   Raw Web MIDI SysEx only. Do not append BLE timestamps.
   Mode device-type byte is 7F; key-color device-type byte stays 05.

Routing:
- Select a recognized connected output and unambiguously pair its input.
- Learn channel/address from CMD 02 reports; assign unassigned main-keyboard
  channels only as the bundled controller does, then wait for its response.
- Broadcast 7F/7F on the selected bus until the route is known.
  Do not hard-code 3F for every device.
- CMD 04/05: debounced topology query; CMD 18: octave update.
  Do not send idle mode during play, or CMD 19 to reset the player's octave.
- Query silence is not proof of disconnect.

Lifecycle and transport:
- Keep existing Web MIDI / native-injected access. App owns USB/BLE pairing.
- A successful {sysex:true} request is required. If an injected access object
  omits sysexEnabled, accept only that successful request without explicit
  denial. Normal MIDI fallback does not grant lighting permission.
- Frame data must be 7-bit; reject out-of-range notes rather than transposing.
- Delta updates use actually written notes. Recompute each small packet;
  rapid changes and pause cancel stale unsent targets.
- Pause/stop: clear. Reconnect: init, clear, restore CURRENT active target.
  Exit: dispose timers and clear best effort, without closing shared ports.
- "sent" means MIDI API writes completed, NOT a physical LED acknowledgment.
- Preserve velocity, channels, multi-message packets, running status and CC64.
  Screen and lighting use the same actual MIDI target notes.
- Never use Note On for lighting: echo can create false player input.
- No global mute. If audio routing is separately requested, confirmed external
  speaker presence may mute web PIANO ONLY, never SFX/BGM. CMD 05 is not proof.
- MidiBrowser's 256-byte ceiling does not supersede the relay's 16-byte limit.
  Future MIDI timestamps may be ignored. 120 ms settle is NOT LED latency;
  do not blindly delay all audio by the old 200 ms direct-device estimate.

SEPARATE DIRECT RGB PROFILE (not a speaker-relay fallback)
Only for explicitly verified direct RGB devices:
mode F0 05 30 7F 7F 20 00 0F 01 F7; command 15; key indices 0–35.
Each RGB group: R_hi R_lo G_hi G_lo B_hi B_lo count key...
8-bit color v -> floor(v/128), v%128. Data bytes <=127; frame <=256.
Historical 12-pair / fixed-3F / dim-palette examples require firmware checks;
do not transplant them into the verified relay.

SEPARATE POPUPIANO 29 PROFILE
Manufacturer 03; MIDI notes 48–76; lamp IDs 0–28.
CMD 1E uploads 7-bit RGB palette; CMD 20 assigns lamp/slot pairs.
No PartyKeys mode packet. Not covered by the relay hardware confirmation.

Validate with the bundled 25 offline tests plus target-project checks.
Real-hardware coverage is limited to the user-confirmed chain; do not claim
every phone, firmware or multi-keyboard setup has passed.
Integration does not authorize unrelated deployment or account changes.
Docs: https://protocol.partykeys.ai/#verified-lighting

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

Apply the explicit device profiles and safety rules below. The reusable
Codex skill is $partykeys-protocol. Keep its three ESM files together and
integrate with the existing input/connection lifecycle; do not replace audio
or scoring just to add lighting.

You are implementing PartyKeys / 音乐密码 lighting. Select an explicit device
profile before constructing any packets. Do not guess unknown MIDI outputs.

VERIFIED WEB MIDI PROFILE — fruit-webmidi-palette71-v1 (2026-10-10)
User-confirmed in PartyKeys ChordCut: phone USB to the PartyStudio APP port,
then wireless to keyboard. Recognized relay aliases: PartyStudio, SAM5704.
Ordinary MIDI input is allowed for playing/scoring; proprietary output is not.

Reuse the complete Skill and dependency-free modules:
https://protocol.partykeys.ai/downloads/partykeys-protocol.zip
https://protocol.partykeys.ai/skills/partykeys-protocol/SKILL.md
https://protocol.partykeys.ai/skills/partykeys-protocol/references/verified-web-midi.md

Wire sequence (hex):
1. Open selected output, mode: F0 7F 30 7F 7F 20 00 0F 05 F7
2. After 8 ms, all off: F0 05 30 7F 7F 20 00 71 00 F7
3. Settle 120 ms, query: F0 05 30 7F 7F 20 00 01 F7
4. Palette target:
   F0 05 30 <channel> <address> 20 00 71 <count> <note color pairs> F7
   Actual MIDI notes 48–83, color 08 (cyan), 00 off.
   Maximum THREE pairs / 16 bytes; pace packets by at least 8 ms.
   Raw Web MIDI SysEx only. Do not append BLE timestamps.
   Mode device-type byte is 7F; key-color device-type byte stays 05.

Routing:
- Select a recognized connected output and unambiguously pair its input.
- Learn channel/address from CMD 02 reports; assign unassigned main-keyboard
  channels only as the bundled controller does, then wait for its response.
- Broadcast 7F/7F on the selected bus until the route is known.
  Do not hard-code 3F for every device.
- CMD 04/05: debounced topology query; CMD 18: octave update.
  Do not send idle mode during play, or CMD 19 to reset the player's octave.
- Query silence is not proof of disconnect.

Lifecycle and transport:
- Keep existing Web MIDI / native-injected access. App owns USB/BLE pairing.
- A successful {sysex:true} request is required. If an injected access object
  omits sysexEnabled, accept only that successful request without explicit
  denial. Normal MIDI fallback does not grant lighting permission.
- Frame data must be 7-bit; reject out-of-range notes rather than transposing.
- Delta updates use actually written notes. Recompute each small packet;
  rapid changes and pause cancel stale unsent targets.
- Pause/stop: clear. Reconnect: init, clear, restore CURRENT active target.
  Exit: dispose timers and clear best effort, without closing shared ports.
- "sent" means MIDI API writes completed, NOT a physical LED acknowledgment.
- Preserve velocity, channels, multi-message packets, running status and CC64.
  Screen and lighting use the same actual MIDI target notes.
- Never use Note On for lighting: echo can create false player input.
- No global mute. If audio routing is separately requested, confirmed external
  speaker presence may mute web PIANO ONLY, never SFX/BGM. CMD 05 is not proof.
- MidiBrowser's 256-byte ceiling does not supersede the relay's 16-byte limit.
  Future MIDI timestamps may be ignored. 120 ms settle is NOT LED latency;
  do not blindly delay all audio by the old 200 ms direct-device estimate.

SEPARATE DIRECT RGB PROFILE (not a speaker-relay fallback)
Only for explicitly verified direct RGB devices:
mode F0 05 30 7F 7F 20 00 0F 01 F7; command 15; key indices 0–35.
Each RGB group: R_hi R_lo G_hi G_lo B_hi B_lo count key...
8-bit color v -> floor(v/128), v%128. Data bytes <=127; frame <=256.
Historical 12-pair / fixed-3F / dim-palette examples require firmware checks;
do not transplant them into the verified relay.

SEPARATE POPUPIANO 29 PROFILE
Manufacturer 03; MIDI notes 48–76; lamp IDs 0–28.
CMD 1E uploads 7-bit RGB palette; CMD 20 assigns lamp/slot pairs.
No PartyKeys mode packet. Not covered by the relay hardware confirmation.

Validate with the bundled 25 offline tests plus target-project checks.
Real-hardware coverage is limited to the user-confirmed chain; do not claim
every phone, firmware or multi-keyboard setup has passed.
Integration does not authorize unrelated deployment or account changes.
Docs: https://protocol.partykeys.ai/#verified-lighting

FAQ

My color commands do nothing.
Check SysEx capability, selected matching output, profile-specific initialization, relay packet size/pacing and discovered route. Receiving notes does not prove lighting output works. See the verified rules.
LEDs trail my audio.
The older direct-device estimate was 150–250 ms. It is not a measurement of the speaker relay. Do not force a 200 ms audio delay or confuse the controller's 120 ms initialization settle with LED latency.
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.