Internals

How the instrument is built. What it does is Features; how to install it is Manual installation.


The control path

A pad press reaches the sequencer through five hops, and every one of them has broken at least once:

flowchart LR HID["Maschine MK2
USB HID"] --> D["maschine daemon
Rust"] D --> A["a2jmidid
virtual port"] A --> Z["ZynMidiRouter
zmip slot"] Z --> C["ctrldev driver
Python"] C --> S["zynseq
Zynthian's sequencer"]

The daemon owns the hardware: it reads HID reports, drives both 255×64 displays and the LEDs, and exposes an ALSA sequencer port. a2jmidid lifts that port into JACK. Zynthian then has to be persuaded to treat it as a hardware MIDI source so it is given a zmip slot — and that is the patch below.

The daemon listens on one socket, and it is loopback only. OSC on 127.0.0.1:42434 is how the driver paints the LEDs and the screens — the instrument talking to itself.

There used to be a second one, and it was the worst thing in this project. A WebSocket on port 9001 served a pad-and-encoder editor, listening on every interface with no authentication while the daemon runs as root; its commands remapped what the pads send and wrote that to disk. Anything on the same network owned the instrument's mapping. It was removed on 5 September 2026 — along with a whole second sequencer that lived inside the daemon — rather than narrowed to loopback, because nothing in this instrument had ever used either. That is about eight hundred lines of inherited code gone, and one fewer thing listening on your network. Read

Everything either socket carries is bounded before it is used. Both take an index — which pad, which screen, how wide a rectangle — and until 3 September 2026 several of those went straight into fixed-size arrays or into loop counts: one WebSocket message could end the daemon, and one OSC rectangle could hang it in a loop with nothing crashing, so systemd never restarted it. The same round bounded the HID side, where a truncated report from the controller could be read as pad pressure — a note nobody played.

The encoders are absolute in hardware; “relative” is what the driver makes of them. The MK2’s report descriptor declares eight 16-bit fields with a maximum of 999, so each encoder reports a position, not a movement. The daemon holds its own value as device state and moves it by the difference between one report and the next. Real movement is 0-4 units per report, so a jump of 8 or more is read as a counter wrap rather than a turn. That threshold used to be explained by a number that was never measured — the code claimed a dropped report produced −38 to −40, which arrived with the inherited source and which the arithmetic cannot produce at any speed. What was really happening was worse and older: the two halves of each 16-bit reading were being paired with each other out of order, so every encoder threw a spurious lurch four times per revolution for the whole life of the daemon. That is fixed, and the threshold stays at 8 for a reason worth knowing — a dropped report is not a different kind of movement, it is the same movement measured over a longer gap, so no threshold can separate the two and only a timestamp could. The big encoder is not one of those eight: it is a 4-bit counter sharing a byte with a second one, it sends 8 units per detent and wraps 120 → 0, sitting exactly on that threshold, so it needs its own delta path.


Why zynautoconnect has to be patched

It is the only Zynthian core file this project changes, and it ships as an idempotent patcher rather than a copy, so it edits your version instead of overwriting it.

Two changes. The Pads port joins the list of ports treated as hardware MIDI sources, which is what earns it a zmip slot. And the uid virtual:maschine.rs/Maschine MK2 Pads is pinned, because the ALSA client number embedded in the port name changes across boots while a ctrldev driver binds by device id.

Unpatched, the driver never binds: Zynthian gets as far as Found and never reaches Loaded. The rig does nothing, and no log anywhere says why — literally, because both of those messages are logged at INFO while ZYNTHIAN_LOG_LEVEL defaults to WARNING, so neither is written on a stock rig. The check that works is the JACK route: exactly one ZynMidiRouter:devN_in under the Pads port.

Re-run the patcher after every Zynthian update: an update replaces the file and takes the binding with it.


The driver

The generator owns the pattern. The driver does not synthesise notes at play time — it writes them into zynseq, which is why patterns survive in snapshots and why the touchscreen editor shows the same thing the pads do. Playing is Zynthian's job; the driver only decides what is written.

Four facts hold the design together.

Everything testable lives outside the driver. The driver itself cannot be imported off the Pi — it needs zynlibs.zynseq — so the Turing register and its mutation, the undo ring, register-to-pitch quantisation, euclidean placement, the gate mask, and the whole page and column model were pushed into techno_lib.py, which has no Zynthian imports. That module carries its own test suite, including the invariant the instrument rests on: a generator at LOCK produces a byte-identical register over 500 iterations — which now has to hold for the rhythm register as well as the pitch one.

Every zynseq call holds one lock. libzynseq is not thread-safe and the driver reaches it from three threads — the MIDI handler, a queued signal handler and a 30 Hz playhead poll. Without the lock the entire Zynthian UI died with a segfault about 95 seconds into a jam.

Nothing slow runs on the MIDI thread. Loading a preset blocks on a socket for seconds while the MIDI handler holds the lock for the whole event, so preset and kit changes are deferred to the poll thread.

What cannot be imported can still be parsed. The driver is eight thousand lines that no test can load, and for a long time the only checks on it were that it compiled and that somebody played it. Then three crashes in one day shared a shape a compiler cannot see: a method defined twice in a class body, where Python silently keeps the second; an assignment to a read-only property; and a constant deleted a hundred lines from the code that read it. None of them is a runtime question. All three are questions about the file’s syntax tree, and the test suite now asks them — along with whether every name the driver reads from its own library actually exists there, which is the one that would have caught the surface failing to load at all. On this half of the project, anything static is worth checking statically, because the alternative is a deploy.

A silent channel must say why. Play chance 0 emits nothing and looked exactly like a hang, so a channel that cannot sound draws its tab dashed. It is the one mechanism the surface has for explaining silence.


The write budget

Every LED write is diffed, and the pads are the one surface that also needs a timer. The driver caches what it last sent, so a quiet poll puts nothing on the wire — a sustained full-grid repaint at the poll rate has flooded the controller off the USB bus, and only a physical replug brought it back. But a cached write the daemon never applied would then stay wrong forever, so pad entries expire after three seconds and the poll thread re-asserts the grid on the same period: about five pad values a second averaged, and every pad LED on the panel is one HID report however many of them changed.

The number that used to sit here was wrong, and the correction is worth knowing. It said “four hundred and eighty writes a second”, arrived at by multiplying sixteen pads by thirty. That arithmetic cannot happen: all sixteen pad LEDs live in one report, setting a pad only marks it dirty, and the sole write site is a dirty-gated flush of exactly three reports on a sixteen-millisecond timer. So painting one pad and painting all fifty-five cost the same, and LED traffic is capped at a hundred and eighty-seven writes a second whatever the driver does. The wedges were real — three in one session, each needing a physical replug — but it is the display path that produces them, at two thousand one hundred and twenty bytes for every screen that changes.


Blinking is free and the screens are not, which is why state lives on the lights. A changed display costs 2120 bytes because every repaint clears the framebuffer first, and that is the path that has thrown the controller off the bus. A changed LED costs nothing extra at all: the flush is three reports on a sixteen-millisecond timer whatever has changed, capped around a hundred and eighty-seven writes a second. Ten lights can blink at once for about twenty messages a second. So a state that must be visible goes on a button, and only a number goes on a screen.

And these LEDs do not respond linearly. The panel’s single-colour buttons saturate early: measured against somebody’s eyes rather than arithmetic, 0.30 and 0.35 of full are indistinguishable from full, 0.12 is still nearly full, and 0.03 is the value that reads as on but clearly not full. The light alphabet shipped with dim at 0.35, which collapsed three levels into two and took the whole vocabulary with it. An OSC capture had “verified” that alphabet an hour earlier — at the wire, where the driver really was sending two different numbers. Reading LED bytes is not reading LEDs.

The factory snapshot

Read as 019-dub-factory, the factory snapshot since 2026-09-06. It is built on 018-generative-techno-main-insert and the chain layout below is common to both; where they differ, the row says so.

The eight chains

ChainTitle in the snapshotEngineMIDI channel (0-based in the file)
AKickLS/LinuxSampler0
BSnareLS/LinuxSampler1
CClapLS/LinuxSampler2
DClosed HatLS/LinuxSampler3
EOpen HatLS/LinuxSampler4
FBASSJV/Obxd in 019, JV/JC303 in 0185
GLEADJV/Obxd6
HPADSJV/padthv17

The MIDI channel is the contract, not the title and not the order — the driver resolves a channel to a chain by MIDI channel, so titles are free and numbers are not. All five drum chains share one LinuxSampler process, so eight SFZ kits cost about 250 MB in total rather than per channel.

The insert pair, measured on the wire

zynmixer:output_NNa/b → TAP_Stereo_Echo-NN → TAP_Reverberator-NN → zynmixer:input_17

Echo first, reverb second, so the reverb hears the echo's repeats. The order was read out of jack_lsp -c on the running rig, not assumed.

The inserts are fed from the strip's output, which is what makes them post-fader: they inherit the fader and the mute, so muting a channel takes its reverb and delay tail with it.

Both plugins have a true wet level, not a dry/wet crossfade — sweep the wet to maximum and the dry is still there at the same level. That is what lets encoders 7 and 8 behave like sends. Every cheaper candidate measured turned out to be a crossfade.

Four ports are forced rather than left at their defaults:

PluginPortValueWhy
TAP Stereo EchodryLevel0.0 dBShips at −4 dB; across the pair that quietly costs every channel about 8 dB
TAP Stereo Echolecholevel, recholevel−70.0 dBWet starts closed; encoder 8 opens it
TAP Reverberatordrylevel0.0 dBSame reason
TAP Reverberatorwetlevel−70.0 dBWet starts closed; encoder 7 opens it

Gain staging

In 018: channel strips 0.19, main 0.80. That is measurement, not caution: one sampler channel peaks at 1.24 before the mixer, and eight summed to 2.92 on the main bus — nearly three times full scale. The sampler's own volume is not the lever; taking it from 96 to 40 moved the bus peak by about 1.5 dB.

In 019 the eight strips carry a mix tuned by ear instead — drums forward at 0.67–0.78, voices 8 to 10 dB down — and the main sits at 0.28. That last number is measurement too, and of the same kind: at unity this snapshot peaked +7.0 dBFS with 1.21 % of its samples over full scale, against a crest factor near 18 dB. Eleven decibels down puts the peak at about −4 dBFS, which is where the mix measured before its sends were corrected on 2026-09-04. Read back on the running instrument over forty-eight bars — the repeat length of the slowest modulator, not one bar — with zero samples at or over full scale. The ratios between the eight channels are the owner's; only the output stage moved.

Sixteen inserts without placing sixteen processors

Build one channel by hand on the touchscreen — add TAP Stereo Echo, then TAP Reverberator, set the four values, save — and let tools/build-techno-snapshot.py replicate it. The cloner finds the chain carrying both inserts, appends the same two to every other chain that has a MIDI channel, gives each a fresh processor id, copies the template's fader position, and backs the file up first. Then load and save once more from the touchscreen, so what is on disk is Zynthian's own output rather than the script's.

It deliberately does not build the whole snapshot. Nothing outside the UI process can reach Zynthian's live state manager, and hand-maintaining fader positions in JSON is exactly the kind of guess this project avoids.


Back to: Requirements · Features