A touch lighting desk for a QLC+ show. It runs on a Linux framebuffer device with a touchscreen, talks to the QLC+ 5 web interface of the machine running the show (the master), and draws its pages, sections and controls from a desk map instead of from the Virtual Console's own layout.
The map is written beside the show by qlctool deskmap
(spectalive/qlctool), so any QLC+
workspace that toolkit can describe gets a desk. The desk never trusts the map
on its own: it fetches the console the master actually serves (/vc.json),
validates the map against it, and only then presses widgets over the
WebSocket (/qlcplusWS). A control the console cannot back is shown disabled,
with the reason on its tile, rather than dropped.
If the WebSocket briefly drops, the top status bar says "Esperando conexión..." without covering the controls. Touch actions stay disabled until the live console has been read again. A different show loaded on the master still gets the blocking mismatch card.
Priority hits use the source Flash button instead of starting the map's
ordinary SingleShot chaser. The QLC+ controller must expose that button's
flashMaxHoldMs, flashOverride and flashForceLTP in /vc.json; the desk
rejects a hit when the controller cannot guarantee its deadline or priority
after a lost WebSocket. The tablet also sends
release on touch-up. All Vibra hit sources, including fog, have controller
deadlines matched to their burstMs values.
It was written for, and is shipped on, a Denver TAQ-102 tablet (1024x600 LVDS
panel, Silead touchscreen) by spectalive/taq102,
which cross-compiles it with Buildroot. The defaults in platform/linux-fb/dmxdesk.c (map
under /etc/taq102/, settings under /data/, fonts under
/usr/share/fonts/taq102) are that appliance's; the map, the master and the
input devices can be given on the command line:
dmxdesk [--host H] [--port P] [--map FILE] [--card /dev/dri/cardN]
[--touch /dev/input/eventN] [--power /dev/input/eventN]
[--view PAGE,BANK] [--setup]
dmxdesk --find ADDR/PREFIX PORT # sweep a subnet for a QLC+ master
show/vibra.desk.json is the map of the Vibra show, generated by
qlctool deskmap; the tests use it, with the console captured in
tests/dmx-desk/fixtures/vc-vibra.json, as the real case.
tools/desk-map-version.py <checkout> prints the provenance line (workspace
SHA-256, checkout HEAD, map SHA-256) that an image ships beside the map.
core/ the portable desk, in C: session, /vc.json, the map,
layout, painting, fonts, settings, the master search
and the loop itself (desk_run)
platform/linux-fb/ the Linux framebuffer device: DRM presentation, evdev
touch, power key, backlight, battery and Wi-Fi, and main
dmxdesk.sources every source the executable links, core and platform
tests/dmx-desk/ host tests, one program per *_test.c; fixtures/ and audit/
show/ the Vibra desk map
fonts/ Inter, the typeface the tests measure text with (OFL-1.1)
tools/ host test runner, dependency fetchers, QLC+ probe, icon
and mockup generators
docs/design/ mockups and frames of the desk's design
The loop is the same on every machine. core/desk_run.c turns a
struct desk_app (core/desk_app.h: the show, the models, who owns which
finger, the settings surface, the session) through a struct desk_platform
(core/desk_platform.h): a table of the device calls the desk needs - a
clock, a wait, touches in, the power key, the display, the services behind the
settings surface, the bar's facts, and the frame out. platform/linux-fb/
fills that table (linux_desk_*.c) and main there only parses arguments,
opens the devices and calls desk_run; a second backend fills its own table
and reuses the loop unchanged. tests/dmx-desk/desk_run_test.c drives the
loop through a scripted platform with no Linux under it.
The console the desk drives sits behind a second table, struct desk_console
(core/desk_console.h). The desk sends it commands in its own words
(core/desk_command.h: toggle, stop-all, flash, function on or off, grand
master, speed dial time and multiplier) and reads back events
(core/desk_event.h: a function's state, the grand master, a speed dial's
echo). core/qlc_console.c is the QLC+ adapter: it encodes each command as a
web-access frame, decodes the master's pushes and steps the session. The
snapshot the desk builds its model from is a surface of its own,
struct desk_surface (core/desk_surface.h): one item per control under the
console's id, with its kind (toggle, flash, stop-all, other button, fader,
dial, group), the group holding it, the function it drives and its live
state. The QLC+ adapter translates /vc.json into it (core/qlc_surface_of.c),
so the map validation, the speed dials and the tiles never see QLC+'s widget
tree, and an adapter for another console fills the same surface.
core/osc_console.c is the second adapter: any console driven by plain OSC
over UDP (dmxdesk --osc FILE --host H --port P [--osc-listen PORT]). OSC
cannot ask a console what it has, so a surface file declares it: the same
fields as struct desk_surface, plus the address each control answers to
(format in core/osc_map_parse.h). A press is a button going to 1 and back to
0; a flash or a function carries its 1 or 0, the grand master its level as
0..1, a dial its time and multiplier as ints. A console that reports its state
back ("feedback": true) is heard on the listen port, from its own host only;
one that does not is not heard at all, and has
the desk loop its own commands back, so it shows what it asked for.
tests/dmx-desk/osc_vibra_test.c writes the Vibra console out as such a file
and checks the desk builds the very same model and speed dials from it.
QLC+'s own OSC input was the first real console it drove (2026-10-28, a headless QLC+ 5.2.2 on loopback): buttons, solo frames, flashes and stop-all work as above, with QLC+'s feedback heard. Four things a QLC+ surface file has to say, each found there:
- a speed dial takes its time as 0..1 of the widget's AbsoluteValue range
(
"msRange": [0, 2000]), since QLC+ divides an int above 255 down to 0, and arrives in 8-bit steps (504 ms becomes 501); - its multiplier is one input per factor (
"factorAddresses", mapped to the dial's inputs 6-9 for 1/16x-1/2x, 4 for 1x, 10-13 for 2x-16x); - QLC+ 5 sends no runtime feedback for a speed dial, nor for a fader moved by
its own input, so those items take
"feedback": falseand the desk echoes them; - a widget on a hidden page of a multipage frame ignores external input
(
VCPage::inputValueChangedrequires it visible), so it must be declared"disabled": true; 17 of the 181 controls Vibra's map names are there, the grand master among them.
tools/qlc-osc-surface writes that file from QLC+'s own /vc.json, with
all four rules applied (core/qlc_osc_surface_json.c), so nobody has to
type it. It runs on the bench, not on the tablet:
tools/get-cjson.sh # once, if tools/vendor/cjson is empty
tools/build-qlc-osc-surface.sh # builds output/qlc-osc-surface
curl -s http://<master>:9999/vc.json > vc.json
output/qlc-osc-surface --prefix /vibra --grand-master 266 vc.json > vibra.osc.json
output/qlc-osc-surface --prefix /vibra --channels vc.json > vibra.osc.channels
dmxdesk --osc vibra.osc.json --host <master> --port 7700 --osc-listen 9000 --map vibra.desk.jsonThe surface file alone is half of it: QLC+ must also listen. The
workspace needs an OSC input on universe 1 (QLC+ listens on UDP 7700 for
universe 1, 7700 + n - 1 in general), and the same line as its feedback
patch with feedbackIP set to the desk's address and feedbackPort to
--osc-listen (9000 by default for universe 1). Each widget then needs
the external inputs the --channels table lists, one line each:
<widget id> <input id> <channel> <path>. The channel is the CRC-16 that
QLC+'s OSC plugin computes from the path, and --channels checks it
against a mapping QLC+ accepted. Input 0 is a button's press, a fader's
level or a dial's wheel; a dial's multipliers are its inputs 4 and 6-13.
Vibra has 518 such inputs plus 18 multiplier buttons, too many to enter by
hand, so the show generator writes them: spectalive/qlctool v0.2.6 and
later, given [controllers] osc = "/vibra" in the show description, adds
an OSC input universe after the show's own and binds every widget by this
same rule. Beside Vibra's one DMX universe that is the second universe,
UDP 7701, with feedback to port 9001 by default; pass --port 7701 --osc-listen 9001. The generator leaves feedback out, because its
address is the desk's on the night: add the feedback patch on the same
line in QLC+'s Inputs/Outputs tab.
Three limits remain. The hidden-page rule takes the frame pages that were
showing when /vc.json was served, so take it with the operator's usual
pages up; a burst whose priority Flash sits on a hidden page is disabled
with it. A burst goes out as a press of its priority Flash widget, which
every map spectalive/qlctool generates names (priorityWidget;
osc_vibra_test checks all 25 of Vibra's); only a hand-written map
without one falls back to DESK_CMD_FUNCTION, which reaches QLC+ only
through a functions entry in the surface file, and is otherwise dropped.
A dial's time arrives in QLC+'s 8-bit steps.
The desk needs a C99 compiler, libdrm, cJSON 1.7.19 and stb_truetype. Both third-party sources are fetched, not vendored in the repository, at the versions the Buildroot build pins:
tools/get-cjson.sh # cJSON 1.7.19 into tools/vendor/cjson/
tools/get-stb.sh # stb_truetype.h at Buildroot 2026.02.3's stb commitcJSON is compiled into the executable rather than linked as a shared library. On a Linux host with libdrm's headers:
mkdir -p output/include/cjson output/include/stb
cp tools/vendor/cjson/cJSON.h output/include/cjson/
cp tools/vendor/stb/stb_truetype.h output/include/stb/
cc -O2 -w -c tools/vendor/cjson/cJSON.c -o output/cJSON.o
cc -std=gnu99 -Wall -Wextra -Werror -O2 -Icore -Iplatform/linux-fb -Ioutput/include \
$(pkg-config --cflags libdrm) -o output/dmxdesk $(cat dmxdesk.sources) output/cJSON.o \
$(pkg-config --libs libdrm) -lm -pthreadThe device build is the Buildroot package br2-external/package/dmxdesk in
spectalive/taq102, which fetches a tagged release of this repository and
compiles the same file list with the same flags.
The host tests run on macOS, with AddressSanitizer and UndefinedBehaviorSanitizer:
tools/get-cjson.sh && tools/get-stb.sh # once
tools/test-dmx-desk-host.shEach tests/dmx-desk/*_test.c names the sources it links in its
// SOURCES: line; the runner builds and runs every one and exits non-zero if
any fails. On a Linux host six of them do not pass yet (see TODO.md). Before them it compiles every core/ source with only core/ on the include path (core_without_platform), so a core file that reaches for a platform header fails the run.
GPL-2.0-or-later, in LICENSE, the terms of the TAQ-102 repository this code
was developed in. The Inter fonts are under the SIL Open Font License 1.1
(fonts/LICENSE-Inter.txt); cJSON (MIT) and stb_truetype (public domain or
MIT) are fetched from their upstreams and not redistributed here.