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.
Verified lighting rules
Reuse the PartyKeys ChordCut lighting implementation: Web MIDI → PartyStudio / SAM5704 speaker relay → keyboard. Keep device profiles separate.
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.
PopuPiano 29
Separate manufacturer 03, commands 1E / 20 / 23, lamp IDs 0–28. Not covered by this relay hardware confirmation.
Initialization and small-packet rules
- After output opens, enter palette mode using the
7Fdevice-type byte; key-color frames still use05. - 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.
- 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.
- Send raw Web MIDI SysEx, without BLE timestamps. Recompute deltas after each successful write so a new chord or pause cancels stale pending notes.
# 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; use7F/7Fbroadcast on that bus until known, not a hard-coded3F. - CMD
04/05triggers a debounced requery; CMD18updates 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
05is 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.
connect-lighting.mjs · partykeys-lights.mjs · partykeys-routing.mjs
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.
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.
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
Plug in a PartyKeys 36, open Chrome DevTools on any page, and paste:
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
]);
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.
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.
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.
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_lo | 2 | 0x00–0x01, 0x00–0x7F | Red channel (14-bit MIDI-safe) |
G_hi G_lo | 2 | same | Green channel |
B_hi B_lo | 2 | same | Blue channel |
keyCount | 1 | 1–36 | Number of key indices that follow |
key0 … keyN | N | 0–35 | Key indices (0 = leftmost C3, 35 = rightmost B5) |
Color encoding — MIDI is 7-bit, so each 0–255 channel splits into a pair:
// 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) | 0x00 | 0x00 | 00 00 |
51 | 0x00 | 0x33 | 00 33 |
128 | 0x01 | 0x00 | 01 00 |
255 (max) | 0x01 | 0x7F | 01 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.0 | The key the player should press now |
| Darklight (预亮) | Send_LightUp (0x14) | paletteUI_k[n+14] | × 0.75 | Next upcoming key, dimmed as a preview |
Palette layout (28 slots). The UI palette is structured as:
| Slots | Color group |
|---|---|
0 | Black (off) |
1 – 12 | Highlight colors — full brightness × 1.0 |
13 | Black (off) |
14 – 25 | Darklight colors — dim × 0.75 (same hues as 1–12) |
26, 27 | White |
Wire format. Both states are emitted as CMD 0x71 palette assignment. To light an entire C3 octave (keys 48–59) at Highlight or Darklight:
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.# 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
F0 05 30 7F 7F 20 00 71 00 F7
6. Key layout
| Key index | MIDI note | Note name | Position |
|---|---|---|---|
0 | 48 | C3 | Leftmost |
17 | 65 | F4 | Middle |
35 | 83 | B5 | Rightmost |
General formula: midi_note = key_index + 48. 36 keys total, 3 octaves C3–B5.
0x90 note 0x40) makes the firmware echo a note-on back as if the player pressed it. Use CMD 15 — it doesn't echo.
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)).
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.
F0 03 20 <numPairs> <lampID paletteSlot> × N F7
SDK Examples
Same task in four languages: connect, init, light keys 17–19 red/green/blue.
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
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
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.
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.
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.
# 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.
LEDs trail my audio.
My input handler fires when I light a key.
0x90 note 0x40). The firmware echoes those as fake player input. Switch to CMD 15 — it doesn't echo.