The M-VAVE SMC-PAD (16 RGB pads, 8 encoders, 5 transport buttons) is a cheap Bluetooth MIDI controller whose pad LEDs do not answer MIDI at all, so QLC+'s feedback cannot light them. This repository holds the bridge that does, and the Swift tools that mapped the pad's MIDI and reverse-engineered its LED protocol on 2026-08-29.
It works with any QLC+ show that binds console widgets to the pad. It was
written for the Vibra lighting show
(Vibra-Lab/vibra-lighting),
which is the example below. The narrative - how the protocol was found and the
dead ends - is in docs/smc-pad-led.md; this file is the
reference for the bridge, the tools and the wire protocol.
qlc_led_bridge.swift holds the pad's LED session over Bluetooth LE and
publishes a virtual CoreMIDI destination, "SMC-PAD LED Bridge". QLC+ sends
its widget feedback there; the bridge paints the pad under the note: the pad's
active colour while the widget is on, its idle colour otherwise.
Which pad wears which colour is the show's, not the bridge's. qlctool pad-palette in spectalive/qlctool
writes it from a saved workspace, from the same bindings and colours the
generated console paints its buttons with, so the pad and the screen read as
one surface:
qlctool pad-palette --out "Show.pads.json" "Show.qxw"The format is documented in qlctool's
docs/pad-palette.md.
The bridge reads format 1 and refuses any other, and refuses a file it cannot
paint faithfully (a note outside 36-67, a note twice, a colour that is not
three channels of 0-255). It lights pads by note and uses active and idle
exactly as given; it computes no brightness of its own. What stays in the bridge
is the device: the note range and the flash address of each note's colour,
0x418 + (note - 36) * 26.
macOS with the Xcode command line tools (swiftc, codesign). From a clone:
git clone https://github.com/spectalive/smc-pad.git
smc-pad/install-bridge.sh "/path/to/Show.pads.json"The Vibra rig, whose palette ships next to its workspace:
~/p/smc-pad/install-bridge.sh ~/p/DMX-Fixtures-qlctool/"QLC+ Setups/Vibra.pads.json"The installer compiles the bridge, checks the palette with it (a file the
bridge would refuse stops the install), wraps it in a signed .app under
~/Library/Application Support/SMC-PAD LED Bridge/ with the palette and the
unlock copied into its Resources, and registers a launchd agent
(com.spectalive.smc-pad-led-bridge) that starts it at login and restarts it
if it dies. Log: ~/Library/Logs/smc-pad-led-bridge.log. Rerun it whenever the
show's palette changes. The script's own comments carry the traps it exists to
avoid.
The palette goes into the bundle rather than onto the agent's command line on purpose: macOS only offers the Bluetooth prompt to an app launched from the Finder, and a Finder launch passes no arguments, so a bridge that could only be told its palette on the command line could never be granted Bluetooth.
An older install from the Vibra repository (label
com.vibra.smc-pad-led-bridge) makes the installer stop and print how to
remove it: two bridges would fight over the pad's one BLE session.
swift qlc_led_bridge.swift --palette Show.pads.json # foreground, from this directory
swiftc -O qlc_led_bridge.swift -o qlc-led-bridge
./qlc-led-bridge --print-palette Show.pads.json # what each note would show| Option | Meaning |
|---|---|
--palette FILE |
The pad palette. Without it, the bridge reads palette.json from its bundle's Resources, and stops if there is none. |
--unlock FILE |
The session unlock. Without it: the bundle's Resources, then reference/gatt_unlock.txt. |
--print-palette FILE |
Parse the palette as the bridge does, print one line per note (pad, flash address, active, idle, control) and exit. Touches no Bluetooth and no MIDI. |
--simulate-notes FILE |
Read a palette (--palette, as above) and a file of raw MIDI bytes, one message per line (e.g. 90 24 7F), and print what each NoteOn/NoteOff would paint - the same decision handleMIDI makes on live CoreMIDI input. Touches no Bluetooth and no MIDI. |
tests/test_print_palette.sh compiles the bridge and runs both modes: --print-palette on
tests/fixtures/Vibra.pads.json (written by qlctool pad-palette from the
Vibra show's Vibra.qxw) against its expected reading, and --simulate-notes on
tests/fixtures/Vibra.notes.txt against Vibra.notes.expected.txt; then it
checks each refusal, including an unrecognized argument. Last, it runs
reference/test_color_cmd.py, the unit test of the protocol's worked example
reference/color_cmd.py, with python3 -m unittest.
The throwaway-but-kept probes. Run any of them with swift <file>.swift; they
need macOS with CoreMIDI/CoreBluetooth access granted to the terminal.
| File | What it does |
|---|---|
midicap.swift |
Listen on every SMC-PAD CoreMIDI source and print each message decoded. This produced the input map in the Vibra show's QLC+ InputProfiles/M-VAVE-SMC-PAD.qxi. |
midisend.swift |
Send one MIDI message (hex bytes as args) to the SMC-PAD-Master port. Used to prove notes/CC do not drive the LEDs. |
blescan.swift |
Scan BLE, connect to the pad, enumerate GATT services and characteristics. |
midiports.swift |
List every MIDI source under the name QLC+ uses for it (CoreMIDI Model, falling back to the display name), with its UID and the name macOS shows. This is how you find out which line the workspace's <Input Name="..."> actually matches. |
bletool.swift |
Connect over BLE (retrieving the bonded peripheral), subscribe to notify chars, and write packets to AE41. Each arg is a hex packet without checksum - the tool appends (~sum)&0xFF; prefix raw: to send verbatim. |
Over USB the pad is three CoreMIDI ports: SMC-PAD-Master (pads/knobs),
SMC-PAD-Private (config/firmware), Puerto 3 (the 3.5mm MIDI out); over
Bluetooth it is a fourth, SMC-PAD Bluetooth, carrying the same messages.
Measured 2026-08-29 with midicap.swift, owner pressing:
- Pads send notes on MIDI channel 10, numbered as the panel is printed: PAD1 bottom-left, PAD13 top-left. Within a bank the note is 35 + pad (PAD1 = 36, PAD13 = 48, PAD16 = 51).
- PAD BANK moves the whole surface up one bank of 16 notes - PAD1 answered 52 - and the pad remembers which bank it is on across power cycles. The show uses two: the hits on bank 1, the JUGAR/page-2 hooks on bank 2.
- SHIFT sends nothing. It picks the functions silkscreened on the pads (SWING, LATCH, SYNC, TAP TEMPO), which never leave the device. Nothing on a console can be bound to it.
<and>send CC 25/26 and do not change the bank, so they are safe as console page arrows. The other three edge buttons are CC 27/28/29.- Encoders send CC 30-37 absolute, on channel 1.
QLC+ must run its MIDI input in omni ("1-16") mode, or it never ORs the MIDI
channel into the channel number and every pad binding addresses the wrong
control. The map lives in
qlctool/generate/smc_pad_device.py in https://github.com/spectalive/qlctool; the bindings
(smc_pad_bindings.py) and the Vibra show's profile QLC+ InputProfiles/M-VAVE-SMC-PAD.qxi
are both derived from it - regenerate the profile with
qlctool input-profile, never by hand.
The pad LEDs do not respond to MIDI. The official app MidiSuite.apk
(Flutter) drives them over Bluetooth LE GATT, decompiled with Blutter
(Dart 3.9.2, snapshot 97ff04a728735e6b6b098bdf983faaba).
- Service
AE40: characteristicAE41(writeWithoutResponse, commands in),AE42(notify, replies out). A second command serviceAE00hasAE01/AE02in the same shape. - Char
7772E5DB-3868-4112-A1A9-F2669D106BF3: BLE-MIDI, the pad's button presses stream here (e.g.80 80 99 16 7F= BLE-MIDI header + NoteOn ch10).
This is what the Android decompile suggested, and it is kept because the GATT
tools still speak it. The constant is not 0xB2: decoding the pad's own
status and OK packets proved the logical packet starts 00 59 - see "The wire
protocol" below, which is the form to build against.
[0xB2, type, ...payload, checksum]
type: 0x44 write 0x46 read 0x22 name/version
checksum: (~(sum of all preceding bytes)) & 0xFF
Only three packet builders exist (makeWritePacket, makeReadPacket,
makeNameAndVerPacket in bluetooth_packet_tools.dart) - there is no
separate "LED mode" command, so a colour write is an ordinary 0xB2 0x44
packet. Worked examples the tool produces:
B2 22 00 00 00 2B (name/ver), B2 46 10 00 00 F7 (read).
- The pad advertises only in Bluetooth pairing mode; once bonded it stops
advertising - retrieve it with
retrieveConnectedPeripherals, not a scan. - The cable may stay in. The pad sends on every transport it has open at once:
with USB connected and all three
SINCOports enumerated, presses still arrive on the7772E5DBchar. (An earlier note here said the BLE side goes silent under USB - corrected 2026-08-29 against the device.)
The real desktop editor is MidiSuite for macOS (MidiSuite_new.dmg on
m-vave.com, a Flutter app with flutter_midi_command + universal_ble; the
earlier CubeSuite is a looper tool and does not know the pad). Its
"Export preset (.spc)" writes the pad config to a file; reference/preset4.spc
is a captured export and reference/decode_preset.py parses it. Each pad's
colour is one record:
09 <id> 00 7F <R> <G> <B> FF
id = pad MIDI note (bank A 0x04-0x13, bank B 0x14-0x23)
R,G,B = full 8-bit channels 0-255 (F0 F0 00 yellow, F0 00 F0 magenta)
Confirmed against the editor's on-screen colours (the displayed yellow pads and
the magenta pad 16 matched F0F000 / F000F0 in the file byte-for-byte). So
the colour model is plain per-pad 24-bit RGB, keyed by note number.
Writing that 09 id 00 7F RGB FF record at the pad - raw to BLE AE41 and
AE01, with and without a frame, USB plugged and unplugged, app open and
closed - never changed an LED. Two reasons, both settled below: SysEx data bytes
must be under 0x80 while the RGB channels are 8-bit, so the payload needs the
app's 7-bit codec; and a write is ignored until the connect session is
unlocked.
The macOS App snapshot (Dart 3.12.2) WAS decompiled, with Blutter's Mach-O
branch (PR 204) patched for a macOS target. The decompiled
core/usb/sysex_codec.dart, midi_transport.dart, usb_connect.dart are in
reference/mac-decompile/. From them the full wire format is known and, where
checkable, validated against real captured packets:
- Logical packet:
00 59 <cmd> <len24-le> <data...> <checksum>, wherechecksum = (~sum(data)) & 0xFF. (An earlier pass read the constant as0xB2; decoding the pad's real status and OK packets proved it is0x59-toMidi([00,59,...])reproduces the device's realF0 00 32 ...header.) - USB transport:
SysexCodec.toMidibit-packs that whole logical packet LSB-first into 7-bit bytes betweenF0andF7. Round-trips exactly against the captured status frameF0 00 32 0D 21 ...and the OK frameF0 00 32 01 08 ... F7(regression-tested inreference/test_color_cmd.py). - Colour write:
_wColor->_write(5, address, [R,G,B])->flashWrite, giving data05 <address-le32> 03 00 00 R G B. No handshake precedes it; no separate mode packet exists in the code. - BLE:
makeWritePacketemits the same logical packet raw toAE41(no 7-bit transform).
reference/color_cmd.py generates both wire forms for any (pad, r, g, b).
The LEDs now change from our own code. The missing piece was not the bytes -
it was the session. A one-shot colour write is ignored; the pad only
accepts writes after a client has replayed the desktop app's whole connect
handshake on that same MIDI connection. Captured from the official app with
MIDI Monitor's output spy (reference/captura.mmon), the unlock is:
- Discovery:
F0 00 32 45 00 00 00 40 7F F7. - Read the entire config: a run of
F0 00 32 0D 41 ...read requests with incrementing 32-bit addresses (0x000, 0x771, 0xF62, 0x1753, ...), each answered by a0D 49config chunk. Reading the whole config is what unlocks writes. - Then the colour write
F0 00 32 09 59 ...is accepted and the LED changes instantly; the pad repliesF0 00 32 01 08 ... F7(OK). - Keep the connection alive with the
0D 41poll (~2/s).
replay.swift does exactly this: it replays the captured unlock frames
(reference/replay_frames.txt), then writes a colour, then polls. Confirmed
live - a pad changed colour the instant the write landed. Run:
swift replay.swift <flash-address> <r> <g> <b> (e.g. ... 1048 0 0 255 sets
address 0x418 blue).
The desktop app configures only over USB, and the pad's BT-MIDI CoreMIDI
endpoint does NOT carry the config/colour protocol (a MIDI session replay to it
draws zero replies). But over Bluetooth the colour path is GATT, exactly as
the Android app uses it: service AE40, write to AE41, notify on AE42. The
same session unlock applies - write the logical unlock packets (the USB unlock
frames decoded back to logical 00 59 ... form, reference/gatt_unlock.txt) to
AE41, and the pad answers on AE42; then write the colour logical packet
00 59 22 <len24> 05 <addr32> 03 00 00 R G B <cksum> to AE41.
gatt_replay.swift does this and was confirmed live: the unlock drew 109 AE42
replies and a pad turned white over Bluetooth. So the LED feedback can be fully
wireless - the pad is a QLC+ input over BT-MIDI and an LED output over BT-GATT
at the same time, no cable.
qlc_led_bridge.swift is the daemon that makes the feedback real. For a machine
that runs the show, install it once (see "Install" above) and forget it:
./install-bridge.sh Show.pads.jsonThe launchd agent starts it at login and restarts it if it dies (verified 2026-08-29 by killing it: launchd brought it back and it reconnected to the pad). The script's own comments carry the three traps it exists to avoid.
The one that will catch you: Bluetooth permission. macOS gates Bluetooth
behind TCC, and a launchd agent cannot show the prompt - it starts, publishes
its MIDI port, logs nothing wrong, and silently never connects. The tell is a
log that stops after bridge running with no pad connected line. Fix it by
opening the app once from the Finder
(open "$HOME/Library/Application Support/SMC-PAD LED Bridge/SMC-PAD LED Bridge.app"),
granting Bluetooth, then
launchctl kickstart -k gui/$UID/com.spectalive.smc-pad-led-bridge.
And: restarting the bridge costs you QLC+'s feedback until you reload. The virtual MIDI endpoint is recreated with a new identity on every start, and QLC+ resolved its feedback patch when it loaded the workspace. The show keeps running; the pads just stop updating until the workspace is reloaded.
To run it in the foreground instead, from this directory (it finds
reference/gatt_unlock.txt relative to the working directory):
swift qlc_led_bridge.swift --palette Show.pads.jsonIt does three things: reads the palette, holds the pad's LED session over BLE GATT (replays the
unlock, then keeps the session alive), and publishes a virtual CoreMIDI
destination "SMC-PAD LED Bridge". In QLC+, Inputs/Outputs tab, on the universe the pad is patched to, add
SMC-PAD LED Bridge and — this is the part the UI makes easy to miss — make it
the universe's Feedback patch, not just an Output. QLC+ only sends widget
feedback if a <Feedback> patch exists; the on-screen "eye" toggle did not
reliably create one here, so the reliable fix is to add it in the saved .qxw:
<Universe Name="Universe 1" ID="0">
<Input Plugin="MIDI" Name="ble device" Line="3"><PluginParameters midichannel="16"/></Input>
<Output Plugin="DMX USB" Name="FT232R USB UART (...)" Line="0"/>
<Feedback Plugin="MIDI" Name="SMC-PAD LED Bridge" Line="N"/>
</Universe>(Line is the plugin's output line for the bridge on this machine; copy it
from the <Output ... Name="SMC-PAD LED Bridge"> line QLC+ writes when you add
it.) Then reload the workspace. Now when a Virtual Console widget becomes
active QLC+ sends the widget's note to the bridge and the matching pad lights.
Confirmed working end to end (the bridge logs MIDI in ... -> pad addr ... and
the pad changes).
Two gotchas that cost time: the bridge output's MIDI Channel must be omni ("1-16") so QLC+ feedback carries the pad's real channel, and the bridge's CoreMIDI endpoint is recreated on every restart — if you restart the daemon, re-select it in QLC+ (or reload the workspace).
Verified end to end: a NoteOn to the virtual port paints the pad over Bluetooth.
The note->address map is one 26-byte record per note slot,
0x418 + (note - 36) * 26, which covers both banks with the same arithmetic
(note 36 = bank 1 PAD1, note 52 = bank 2 PAD1).
Colours are per function, not per state: the palette gives each pad an
active colour, painted while QLC+ reports its widget on, and an idle one,
painted otherwise. Both come from the show's qlctool pad-palette file, which
the toolkit writes from the pad profile's colours, so there is nothing here to
keep in step by hand. Most pad-bound console buttons wear the same colour;
a few page-2 controls wear their page's colour on the console instead.
Apache-2.0 (LICENSE), except reference/mac-decompile/: a disassembly of
M-VAVE's MidiSuite app, kept as evidence for the protocol notes and not
covered by this licence. See NOTICE.